applied-cli 0.1.0__tar.gz

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.
Files changed (40) hide show
  1. applied_cli-0.1.0/PKG-INFO +176 -0
  2. applied_cli-0.1.0/README.md +154 -0
  3. applied_cli-0.1.0/applied_cli/__init__.py +2 -0
  4. applied_cli-0.1.0/applied_cli/auth_store.py +263 -0
  5. applied_cli-0.1.0/applied_cli/commands/__init__.py +2 -0
  6. applied_cli-0.1.0/applied_cli/commands/_hints.py +11 -0
  7. applied_cli-0.1.0/applied_cli/commands/_normalize.py +79 -0
  8. applied_cli-0.1.0/applied_cli/commands/_parsers.py +58 -0
  9. applied_cli-0.1.0/applied_cli/commands/_ui.py +33 -0
  10. applied_cli-0.1.0/applied_cli/commands/agent.py +1231 -0
  11. applied_cli-0.1.0/applied_cli/commands/auth.py +739 -0
  12. applied_cli-0.1.0/applied_cli/commands/chat.py +379 -0
  13. applied_cli-0.1.0/applied_cli/commands/coverage.py +348 -0
  14. applied_cli-0.1.0/applied_cli/commands/discover.py +1006 -0
  15. applied_cli-0.1.0/applied_cli/commands/fix.py +1204 -0
  16. applied_cli-0.1.0/applied_cli/commands/insights.py +614 -0
  17. applied_cli-0.1.0/applied_cli/commands/intents.py +447 -0
  18. applied_cli-0.1.0/applied_cli/commands/rate.py +508 -0
  19. applied_cli-0.1.0/applied_cli/commands/responses.py +604 -0
  20. applied_cli-0.1.0/applied_cli/commands/shop.py +1757 -0
  21. applied_cli-0.1.0/applied_cli/commands/simulate.py +330 -0
  22. applied_cli-0.1.0/applied_cli/commands/spec.py +238 -0
  23. applied_cli-0.1.0/applied_cli/config.py +50 -0
  24. applied_cli-0.1.0/applied_cli/error_reporting.py +38 -0
  25. applied_cli-0.1.0/applied_cli/http.py +1614 -0
  26. applied_cli-0.1.0/applied_cli/main.py +90 -0
  27. applied_cli-0.1.0/applied_cli/mcp_server.py +738 -0
  28. applied_cli-0.1.0/applied_cli/presets/demo.yaml +170 -0
  29. applied_cli-0.1.0/applied_cli/runtime.py +53 -0
  30. applied_cli-0.1.0/applied_cli/shop_spec.py +398 -0
  31. applied_cli-0.1.0/applied_cli/spec_workflow.py +432 -0
  32. applied_cli-0.1.0/applied_cli.egg-info/PKG-INFO +176 -0
  33. applied_cli-0.1.0/applied_cli.egg-info/SOURCES.txt +38 -0
  34. applied_cli-0.1.0/applied_cli.egg-info/dependency_links.txt +1 -0
  35. applied_cli-0.1.0/applied_cli.egg-info/entry_points.txt +3 -0
  36. applied_cli-0.1.0/applied_cli.egg-info/requires.txt +7 -0
  37. applied_cli-0.1.0/applied_cli.egg-info/top_level.txt +1 -0
  38. applied_cli-0.1.0/pyproject.toml +46 -0
  39. applied_cli-0.1.0/setup.cfg +4 -0
  40. applied_cli-0.1.0/tests/test_parsers_and_insights.py +47 -0
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: applied-cli
3
+ Version: 0.1.0
4
+ Summary: CLI and MCP server for Applied Labs AI support agents
5
+ Author: Applied Labs
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/appliedlabs/applied-cli
8
+ Project-URL: Repository, https://github.com/appliedlabs/applied-cli
9
+ Keywords: applied-labs,ai-agents,support,mcp,claude
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: httpx>=0.28.1
17
+ Requires-Dist: keyring>=25.6.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: typer>=0.16.0
20
+ Provides-Extra: mcp
21
+ Requires-Dist: mcp>=1.2.0; extra == "mcp"
22
+
23
+ # Applied Labs CLI
24
+
25
+ CLI and Claude Code plugin for managing Applied Labs AI support agents.
26
+
27
+ ## Installation
28
+
29
+ ### As a Claude Code Plugin
30
+
31
+ ```bash
32
+ # From a marketplace (once published)
33
+ /plugin install applied-labs@marketplace-name
34
+
35
+ # Or test locally
36
+ claude --plugin-dir /path/to/applied-cli
37
+ ```
38
+
39
+ ### As a standalone CLI
40
+
41
+ ```bash
42
+ pip install applied-cli
43
+
44
+ # Or with MCP server support
45
+ pip install "applied-cli[mcp]"
46
+ ```
47
+
48
+ ## Authentication
49
+
50
+ ```bash
51
+ applied-cli auth login # Opens browser for approval
52
+ applied-cli auth status # Check current shop
53
+ applied-cli auth shops # List available shops
54
+ applied-cli auth use-shop NAME # Switch shops
55
+ ```
56
+
57
+ ## Quick Start
58
+
59
+ ### 1. Set up a new shop
60
+
61
+ ```bash
62
+ # Generate spec template
63
+ applied-cli shop template > my-shop.yaml
64
+
65
+ # Edit the spec with your configuration...
66
+
67
+ # Run setup
68
+ applied-cli shop setup --spec my-shop.yaml --json
69
+ ```
70
+
71
+ ### 2. Test your agent
72
+
73
+ ```bash
74
+ applied-cli chat --agent-id <uuid> --message "Hello"
75
+ ```
76
+
77
+ ### 3. Fix failing scenarios
78
+
79
+ ```bash
80
+ # Get context for failures
81
+ applied-cli test fix context --benchmark-id <uuid> --json
82
+
83
+ # Update knowledge base
84
+ applied-cli knowledge upsert --agent-id <uuid> --type qa \
85
+ --question "What is your return policy?" \
86
+ --answer "30 day returns on all items."
87
+
88
+ # Batch test fixes
89
+ applied-cli test fix batch --source <failing-benchmark> --target <validation-benchmark>
90
+
91
+ # Check progress
92
+ applied-cli test fix status --source <source> --target <target>
93
+ ```
94
+
95
+ ## Command Reference
96
+
97
+ ```
98
+ applied-cli
99
+ ├── auth # Login, logout, switch shops
100
+ ├── shop # Bootstrap new shops from YAML spec
101
+ ├── agent # List, create, update agents
102
+ ├── chat # Send a message to an agent
103
+ ├── conversations # List, show, import conversations
104
+ ├── insights # Generate analytics reports
105
+ ├── knowledge # Q&A entries, escalation rules
106
+ ├── taxonomy # Topic/intent classification
107
+ ├── test # Testing workflows
108
+ │ ├── benchmarks # Scenario collections
109
+ │ ├── scenarios # Individual test cases (includes rate)
110
+ │ ├── runs # Execution records
111
+ │ ├── coverage # Coverage summaries
112
+ │ └── fix # Fix failing scenarios
113
+ └── simulate # Generate test conversations
114
+ ```
115
+
116
+ ## MCP Server
117
+
118
+ The CLI includes an MCP server for Claude integrations:
119
+
120
+ ```bash
121
+ # Run directly (after pip install)
122
+ applied-cli-mcp
123
+
124
+ # Or via uvx (after publishing to PyPI)
125
+ uvx --from "applied-cli[mcp]" applied-cli-mcp
126
+ ```
127
+
128
+ ### Claude Desktop Configuration
129
+
130
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "applied-labs": {
136
+ "command": "applied-cli-mcp"
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ ## Plugin Skills
143
+
144
+ When installed as a Claude Code plugin, these skills are available:
145
+
146
+ - `/applied-labs:setup-shop` - Guided shop setup workflow
147
+ - `/applied-labs:fix-scenarios` - Fix failing test scenarios
148
+
149
+ ## Environment Variables
150
+
151
+ | Variable | Description |
152
+ |----------|-------------|
153
+ | `APPLIED_ENDPOINT` | `prod`, `dev`, `local`, or full URL |
154
+ | `APPLIED_SHOP_ID` | Pre-select shop UUID |
155
+ | `APPLIED_API_TOKEN` | Skip browser auth |
156
+ | `APPLIED_PROFILE` | Named credential profile |
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ # Install in development mode
162
+ pip install -e ".[mcp]"
163
+
164
+ # Test CLI
165
+ applied-cli --help
166
+
167
+ # Test MCP server
168
+ applied-cli-mcp
169
+
170
+ # Test as Claude Code plugin
171
+ claude --plugin-dir .
172
+ ```
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,154 @@
1
+ # Applied Labs CLI
2
+
3
+ CLI and Claude Code plugin for managing Applied Labs AI support agents.
4
+
5
+ ## Installation
6
+
7
+ ### As a Claude Code Plugin
8
+
9
+ ```bash
10
+ # From a marketplace (once published)
11
+ /plugin install applied-labs@marketplace-name
12
+
13
+ # Or test locally
14
+ claude --plugin-dir /path/to/applied-cli
15
+ ```
16
+
17
+ ### As a standalone CLI
18
+
19
+ ```bash
20
+ pip install applied-cli
21
+
22
+ # Or with MCP server support
23
+ pip install "applied-cli[mcp]"
24
+ ```
25
+
26
+ ## Authentication
27
+
28
+ ```bash
29
+ applied-cli auth login # Opens browser for approval
30
+ applied-cli auth status # Check current shop
31
+ applied-cli auth shops # List available shops
32
+ applied-cli auth use-shop NAME # Switch shops
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ### 1. Set up a new shop
38
+
39
+ ```bash
40
+ # Generate spec template
41
+ applied-cli shop template > my-shop.yaml
42
+
43
+ # Edit the spec with your configuration...
44
+
45
+ # Run setup
46
+ applied-cli shop setup --spec my-shop.yaml --json
47
+ ```
48
+
49
+ ### 2. Test your agent
50
+
51
+ ```bash
52
+ applied-cli chat --agent-id <uuid> --message "Hello"
53
+ ```
54
+
55
+ ### 3. Fix failing scenarios
56
+
57
+ ```bash
58
+ # Get context for failures
59
+ applied-cli test fix context --benchmark-id <uuid> --json
60
+
61
+ # Update knowledge base
62
+ applied-cli knowledge upsert --agent-id <uuid> --type qa \
63
+ --question "What is your return policy?" \
64
+ --answer "30 day returns on all items."
65
+
66
+ # Batch test fixes
67
+ applied-cli test fix batch --source <failing-benchmark> --target <validation-benchmark>
68
+
69
+ # Check progress
70
+ applied-cli test fix status --source <source> --target <target>
71
+ ```
72
+
73
+ ## Command Reference
74
+
75
+ ```
76
+ applied-cli
77
+ ├── auth # Login, logout, switch shops
78
+ ├── shop # Bootstrap new shops from YAML spec
79
+ ├── agent # List, create, update agents
80
+ ├── chat # Send a message to an agent
81
+ ├── conversations # List, show, import conversations
82
+ ├── insights # Generate analytics reports
83
+ ├── knowledge # Q&A entries, escalation rules
84
+ ├── taxonomy # Topic/intent classification
85
+ ├── test # Testing workflows
86
+ │ ├── benchmarks # Scenario collections
87
+ │ ├── scenarios # Individual test cases (includes rate)
88
+ │ ├── runs # Execution records
89
+ │ ├── coverage # Coverage summaries
90
+ │ └── fix # Fix failing scenarios
91
+ └── simulate # Generate test conversations
92
+ ```
93
+
94
+ ## MCP Server
95
+
96
+ The CLI includes an MCP server for Claude integrations:
97
+
98
+ ```bash
99
+ # Run directly (after pip install)
100
+ applied-cli-mcp
101
+
102
+ # Or via uvx (after publishing to PyPI)
103
+ uvx --from "applied-cli[mcp]" applied-cli-mcp
104
+ ```
105
+
106
+ ### Claude Desktop Configuration
107
+
108
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "applied-labs": {
114
+ "command": "applied-cli-mcp"
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ ## Plugin Skills
121
+
122
+ When installed as a Claude Code plugin, these skills are available:
123
+
124
+ - `/applied-labs:setup-shop` - Guided shop setup workflow
125
+ - `/applied-labs:fix-scenarios` - Fix failing test scenarios
126
+
127
+ ## Environment Variables
128
+
129
+ | Variable | Description |
130
+ |----------|-------------|
131
+ | `APPLIED_ENDPOINT` | `prod`, `dev`, `local`, or full URL |
132
+ | `APPLIED_SHOP_ID` | Pre-select shop UUID |
133
+ | `APPLIED_API_TOKEN` | Skip browser auth |
134
+ | `APPLIED_PROFILE` | Named credential profile |
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ # Install in development mode
140
+ pip install -e ".[mcp]"
141
+
142
+ # Test CLI
143
+ applied-cli --help
144
+
145
+ # Test MCP server
146
+ applied-cli-mcp
147
+
148
+ # Test as Claude Code plugin
149
+ claude --plugin-dir .
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT
@@ -0,0 +1,2 @@
1
+ """applied-cli package."""
2
+
@@ -0,0 +1,263 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from applied_cli.config import Credentials
6
+
7
+ SERVICE_NAME = "applied-cli"
8
+ ACCOUNT_NAME = "default"
9
+ STORE_VERSION = 2
10
+
11
+
12
+ def _fallback_path() -> Path:
13
+ return Path.home() / ".config" / "applied-cli" / "credentials.json"
14
+
15
+
16
+ def _credentials_to_dict(credentials: Credentials) -> dict[str, str]:
17
+ return {
18
+ "base_url": credentials.base_url,
19
+ "shop_id": credentials.shop_id,
20
+ "api_token": credentials.api_token,
21
+ }
22
+
23
+
24
+ def _credentials_from_dict(data: dict[str, object]) -> Optional[Credentials]:
25
+ base_url = str(data.get("base_url", "")).strip()
26
+ shop_id = str(data.get("shop_id", "")).strip()
27
+ api_token = str(data.get("api_token", "")).strip()
28
+ if not base_url or not shop_id or not api_token:
29
+ return None
30
+ return Credentials(base_url=base_url, shop_id=shop_id, api_token=api_token)
31
+
32
+
33
+ def _normalize_store(raw: object) -> dict[str, object]:
34
+ if not isinstance(raw, dict):
35
+ return {"version": STORE_VERSION, "active_profile": None, "profiles": {}}
36
+
37
+ # Legacy single-profile shape:
38
+ # {"base_url": "...", "shop_id": "...", "api_token": "..."}
39
+ legacy = _credentials_from_dict(raw)
40
+ if legacy:
41
+ return {
42
+ "version": STORE_VERSION,
43
+ "active_profile": "default",
44
+ "profiles": {"default": _credentials_to_dict(legacy)},
45
+ }
46
+
47
+ profiles_raw = raw.get("profiles")
48
+ profiles: dict[str, dict[str, str]] = {}
49
+ if isinstance(profiles_raw, dict):
50
+ for profile_name, payload in profiles_raw.items():
51
+ if not isinstance(profile_name, str) or not profile_name.strip():
52
+ continue
53
+ if not isinstance(payload, dict):
54
+ continue
55
+ creds = _credentials_from_dict(payload)
56
+ if creds is None:
57
+ continue
58
+ profiles[profile_name.strip()] = _credentials_to_dict(creds)
59
+
60
+ active_profile_raw = raw.get("active_profile")
61
+ active_profile = (
62
+ active_profile_raw.strip()
63
+ if isinstance(active_profile_raw, str) and active_profile_raw.strip()
64
+ else None
65
+ )
66
+ if active_profile and active_profile not in profiles:
67
+ active_profile = None
68
+ if not active_profile and profiles:
69
+ active_profile = sorted(profiles.keys())[0]
70
+
71
+ return {
72
+ "version": STORE_VERSION,
73
+ "active_profile": active_profile,
74
+ "profiles": profiles,
75
+ }
76
+
77
+
78
+ def _serialize_store(store: dict[str, object]) -> str:
79
+ return json.dumps(
80
+ {
81
+ "version": STORE_VERSION,
82
+ "active_profile": store.get("active_profile"),
83
+ "profiles": store.get("profiles", {}),
84
+ }
85
+ )
86
+
87
+
88
+ def _load_store_from_keyring() -> Optional[dict[str, object]]:
89
+ try:
90
+ import keyring
91
+ except Exception:
92
+ return None
93
+
94
+ try:
95
+ raw = keyring.get_password(SERVICE_NAME, ACCOUNT_NAME)
96
+ except Exception:
97
+ return None
98
+
99
+ if not raw:
100
+ return None
101
+
102
+ try:
103
+ return _normalize_store(json.loads(raw))
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ def _save_store_to_keyring(store: dict[str, object]) -> bool:
109
+ try:
110
+ import keyring
111
+ except Exception:
112
+ return False
113
+
114
+ try:
115
+ keyring.set_password(
116
+ SERVICE_NAME,
117
+ ACCOUNT_NAME,
118
+ _serialize_store(store),
119
+ )
120
+ return True
121
+ except Exception:
122
+ return False
123
+
124
+
125
+ def _clear_keyring() -> None:
126
+ try:
127
+ import keyring
128
+
129
+ keyring.delete_password(SERVICE_NAME, ACCOUNT_NAME)
130
+ except Exception:
131
+ return
132
+
133
+
134
+ def _load_store_from_file() -> Optional[dict[str, object]]:
135
+ path = _fallback_path()
136
+ if not path.exists():
137
+ return None
138
+
139
+ try:
140
+ return _normalize_store(json.loads(path.read_text()))
141
+ except Exception:
142
+ return None
143
+
144
+
145
+ def _save_store_to_file(store: dict[str, object]) -> None:
146
+ path = _fallback_path()
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ path.write_text(_serialize_store(store))
149
+ path.chmod(0o600)
150
+
151
+
152
+ def _clear_file() -> None:
153
+ path = _fallback_path()
154
+ if path.exists():
155
+ path.unlink()
156
+
157
+
158
+ def _load_store() -> dict[str, object]:
159
+ store = _load_store_from_keyring()
160
+ if store:
161
+ return store
162
+ store = _load_store_from_file()
163
+ if store:
164
+ return store
165
+ return {"version": STORE_VERSION, "active_profile": None, "profiles": {}}
166
+
167
+
168
+ def _save_store(store: dict[str, object]) -> str:
169
+ if _save_store_to_keyring(store):
170
+ return "keyring"
171
+
172
+ _save_store_to_file(store)
173
+ return "file"
174
+
175
+
176
+ def save_credentials(
177
+ credentials: Credentials,
178
+ *,
179
+ profile: str = "default",
180
+ set_active: bool = True,
181
+ ) -> str:
182
+ profile_name = profile.strip() or "default"
183
+ store = _load_store()
184
+ profiles = store.get("profiles")
185
+ if not isinstance(profiles, dict):
186
+ profiles = {}
187
+ profiles[profile_name] = _credentials_to_dict(credentials)
188
+ store["profiles"] = profiles
189
+ if set_active:
190
+ store["active_profile"] = profile_name
191
+ return _save_store(store)
192
+
193
+
194
+ def load_profiles() -> dict[str, Credentials]:
195
+ store = _load_store()
196
+ profiles = store.get("profiles")
197
+ if not isinstance(profiles, dict):
198
+ return {}
199
+ out: dict[str, Credentials] = {}
200
+ for profile_name, payload in profiles.items():
201
+ if not isinstance(profile_name, str) or not isinstance(payload, dict):
202
+ continue
203
+ creds = _credentials_from_dict(payload)
204
+ if creds is not None:
205
+ out[profile_name] = creds
206
+ return out
207
+
208
+
209
+ def list_profiles() -> list[str]:
210
+ return sorted(load_profiles().keys())
211
+
212
+
213
+ def get_active_profile_name() -> Optional[str]:
214
+ store = _load_store()
215
+ active = store.get("active_profile")
216
+ return active if isinstance(active, str) and active.strip() else None
217
+
218
+
219
+ def set_active_profile(profile: str) -> bool:
220
+ profile_name = profile.strip()
221
+ if not profile_name:
222
+ return False
223
+ store = _load_store()
224
+ profiles = store.get("profiles")
225
+ if not isinstance(profiles, dict) or profile_name not in profiles:
226
+ return False
227
+ store["active_profile"] = profile_name
228
+ _save_store(store)
229
+ return True
230
+
231
+
232
+ def delete_profile(profile: str) -> bool:
233
+ profile_name = profile.strip()
234
+ if not profile_name:
235
+ return False
236
+ store = _load_store()
237
+ profiles = store.get("profiles")
238
+ if not isinstance(profiles, dict) or profile_name not in profiles:
239
+ return False
240
+ del profiles[profile_name]
241
+ store["profiles"] = profiles
242
+ active = store.get("active_profile")
243
+ if active == profile_name:
244
+ store["active_profile"] = sorted(profiles.keys())[0] if profiles else None
245
+ _save_store(store)
246
+ return True
247
+
248
+
249
+ def load_credentials(*, profile: Optional[str] = None) -> Optional[Credentials]:
250
+ profiles = load_profiles()
251
+ if not profiles:
252
+ return None
253
+ if profile and profile in profiles:
254
+ return profiles[profile]
255
+ active = get_active_profile_name()
256
+ if active and active in profiles:
257
+ return profiles[active]
258
+ return profiles.get("default")
259
+
260
+
261
+ def clear_credentials() -> None:
262
+ _clear_keyring()
263
+ _clear_file()
@@ -0,0 +1,2 @@
1
+ """CLI command groups."""
2
+
@@ -0,0 +1,11 @@
1
+ from difflib import get_close_matches
2
+ from typing import Iterable
3
+
4
+
5
+ def suggest_value(raw: str, options: Iterable[str]) -> str | None:
6
+ value = raw.strip().lower()
7
+ normalized = [item.strip().lower() for item in options if item.strip()]
8
+ if not value or not normalized:
9
+ return None
10
+ matches = get_close_matches(value, normalized, n=1, cutoff=0.6)
11
+ return matches[0] if matches else None
@@ -0,0 +1,79 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ import typer
5
+
6
+ from applied_cli.commands._hints import suggest_value
7
+
8
+ MODALITY_ALIASES = {
9
+ "all": "All",
10
+ "call": "Call",
11
+ "sms": "SMS",
12
+ "email": "Email",
13
+ "chat": "Chat",
14
+ "internal": "Internal",
15
+ }
16
+
17
+ AGENT_TYPE_ALIASES = {
18
+ "generic": "Generic",
19
+ "customer_support": "Customer Support",
20
+ "customer support": "Customer Support",
21
+ "conversion": "Conversion",
22
+ "sales": "Sales",
23
+ "marketing": "Marketing",
24
+ "engineering": "Engineering",
25
+ "product": "Product",
26
+ }
27
+
28
+ RESPONSE_TYPE_ALIASES = {
29
+ "escalation": "escalate",
30
+ "escalate": "escalate",
31
+ "exact": "exact",
32
+ "qa": "qa",
33
+ "context": "context",
34
+ "greeting": "greeting",
35
+ "signature": "signature",
36
+ }
37
+
38
+
39
+ def normalize_question(text: str) -> str:
40
+ return re.sub(r"\s+", " ", text.strip().lower())
41
+
42
+
43
+ def normalize_response_type(
44
+ raw: str,
45
+ *,
46
+ field_label: str = "type",
47
+ include_suggestion: bool = False,
48
+ ) -> str:
49
+ key = raw.strip().lower()
50
+ if key not in RESPONSE_TYPE_ALIASES:
51
+ suffix = ""
52
+ if include_suggestion:
53
+ suggestion = suggest_value(key, RESPONSE_TYPE_ALIASES.keys())
54
+ if suggestion:
55
+ suffix = f" Did you mean '{suggestion}'?"
56
+ raise typer.BadParameter(
57
+ f"{field_label} must be one of: escalation, exact, qa, context, greeting, signature.{suffix}"
58
+ )
59
+ return RESPONSE_TYPE_ALIASES[key]
60
+
61
+
62
+ def normalize_modality(raw: Optional[str]) -> Optional[str]:
63
+ if raw is None:
64
+ return None
65
+ key = raw.strip().lower()
66
+ if key not in MODALITY_ALIASES:
67
+ raise typer.BadParameter("modality must be one of: all, call, sms, email, chat, internal")
68
+ return MODALITY_ALIASES[key]
69
+
70
+
71
+ def normalize_agent_type(raw: Optional[str]) -> Optional[str]:
72
+ if raw is None:
73
+ return None
74
+ key = raw.strip().lower()
75
+ if key not in AGENT_TYPE_ALIASES:
76
+ raise typer.BadParameter(
77
+ "type must be one of: generic, customer_support, conversion, sales, marketing, engineering, product"
78
+ )
79
+ return AGENT_TYPE_ALIASES[key]