voiceops 0.1.3__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.
Files changed (62) hide show
  1. voiceops/__init__.py +3 -0
  2. voiceops/_imports.py +28 -0
  3. voiceops/cli.py +237 -0
  4. voiceops/config/__init__.py +21 -0
  5. voiceops/config/build.py +118 -0
  6. voiceops/config/fragments.py +88 -0
  7. voiceops/config/project.py +159 -0
  8. voiceops/config/push.py +140 -0
  9. voiceops/config/safety/ai_disclosure.md +3 -0
  10. voiceops/config/safety/closing.md +2 -0
  11. voiceops/config/safety/human_on_request.md +4 -0
  12. voiceops/config/safety/identity_before_detail.md +5 -0
  13. voiceops/config/safety/no_unbacked_promises.md +4 -0
  14. voiceops/config/safety/third_party_voicemail.md +4 -0
  15. voiceops/config/safety/upset_caller.md +5 -0
  16. voiceops/config/tools.py +72 -0
  17. voiceops/deploy/__init__.py +6 -0
  18. voiceops/deploy/run.py +128 -0
  19. voiceops/deploy/spec.py +65 -0
  20. voiceops/evals/__init__.py +31 -0
  21. voiceops/evals/elevenlabs.py +168 -0
  22. voiceops/evals/gate.py +189 -0
  23. voiceops/evals/known_answer.py +73 -0
  24. voiceops/evals/leak.py +104 -0
  25. voiceops/evals/scenarios.py +30 -0
  26. voiceops/guard/__init__.py +38 -0
  27. voiceops/guard/fastapi.py +88 -0
  28. voiceops/guard/logs.py +25 -0
  29. voiceops/guard/ratelimit.py +31 -0
  30. voiceops/guard/secrets.py +47 -0
  31. voiceops/guard/tokens.py +186 -0
  32. voiceops/review/__init__.py +21 -0
  33. voiceops/review/calls.py +169 -0
  34. voiceops/review/sheet.py +205 -0
  35. voiceops/scaffold/__init__.py +65 -0
  36. voiceops/scaffold/template/Dockerfile +17 -0
  37. voiceops/scaffold/template/README.md +36 -0
  38. voiceops/scaffold/template/agent/fragments/00_role.md +4 -0
  39. voiceops/scaffold/template/agent/fragments/40_no_decisions.md +3 -0
  40. voiceops/scaffold/template/agent/fragments/50_case.md +3 -0
  41. voiceops/scaffold/template/agent/profiles.json +25 -0
  42. voiceops/scaffold/template/app/__init__.py +0 -0
  43. voiceops/scaffold/template/app/api.py +112 -0
  44. voiceops/scaffold/template/app/models.py +39 -0
  45. voiceops/scaffold/template/app/service.py +181 -0
  46. voiceops/scaffold/template/dot_dockerignore +7 -0
  47. voiceops/scaffold/template/dot_env.example +12 -0
  48. voiceops/scaffold/template/dot_github/workflows/ci.yml +21 -0
  49. voiceops/scaffold/template/dot_gitignore +7 -0
  50. voiceops/scaffold/template/evals/__init__.py +0 -0
  51. voiceops/scaffold/template/evals/known_answer/cases.json +18 -0
  52. voiceops/scaffold/template/evals/leak_vocabulary.toml +14 -0
  53. voiceops/scaffold/template/evals/runner.py +149 -0
  54. voiceops/scaffold/template/evals/scenarios.py +32 -0
  55. voiceops/scaffold/template/pyproject.toml +26 -0
  56. voiceops/scaffold/template/tests/test_api.py +82 -0
  57. voiceops/scaffold/template/voiceops.toml +97 -0
  58. voiceops-0.1.3.dist-info/METADATA +35 -0
  59. voiceops-0.1.3.dist-info/RECORD +62 -0
  60. voiceops-0.1.3.dist-info/WHEEL +4 -0
  61. voiceops-0.1.3.dist-info/entry_points.txt +2 -0
  62. voiceops-0.1.3.dist-info/licenses/LICENSE +21 -0
voiceops/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """voiceops: deployment discipline for voice agents (see docs/PLAN.md)."""
2
+
3
+ __version__ = "0.1.3"
voiceops/_imports.py ADDED
@@ -0,0 +1,28 @@
1
+ """Load a project object named as "package.module:ATTR", relative to the project root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ class ImportSpecError(ValueError):
12
+ pass
13
+
14
+
15
+ def load_object(spec: str, root: Path, what: str = "object") -> Any:
16
+ module_name, _, attr = spec.partition(":")
17
+ if not module_name or not attr:
18
+ raise ImportSpecError(f"{what} must look like 'package.module:ATTR', got {spec!r}")
19
+ added = str(root) not in sys.path
20
+ if added:
21
+ sys.path.insert(0, str(root))
22
+ try:
23
+ return getattr(importlib.import_module(module_name), attr)
24
+ except (ImportError, AttributeError) as e:
25
+ raise ImportSpecError(f"cannot load {what} from {spec!r}: {e}") from e
26
+ finally:
27
+ if added:
28
+ sys.path.remove(str(root))
voiceops/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ """The `voiceops` command. Subcommands land with their modules (PLAN §4.5)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from dotenv import load_dotenv
7
+
8
+ from voiceops import __version__
9
+ from voiceops.config import ConfigError, PushError, load_config, stale_files, write
10
+ from voiceops.config import build as build_files
11
+ from voiceops.config import dry_run as push_dry_run
12
+ from voiceops.config import push as push_profile
13
+
14
+ app = typer.Typer(help="Deployment discipline for voice agents.", no_args_is_help=True)
15
+ CONFIG = typer.Option(Path("voiceops.toml"), "--config", "-c", help="Project config file.")
16
+ DIRECTORY = typer.Option(None, help="Where to create it (default: ./<name>).")
17
+ KIT_SOURCE = typer.Option(None, help="Use a local kit checkout (its packages/ folder), not PyPI.")
18
+
19
+
20
+ @app.callback()
21
+ def main() -> None:
22
+ """Deployment discipline for voice agents."""
23
+
24
+
25
+ @app.command()
26
+ def version() -> None:
27
+ """Print the kit version."""
28
+ typer.echo(__version__)
29
+
30
+
31
+ @app.command()
32
+ def init(
33
+ name: str = typer.Argument(..., help="Project name: lowercase, digits, dashes."),
34
+ organisation: str = typer.Option("Example Organisation", help="Who the agent speaks for."),
35
+ directory: Path = DIRECTORY,
36
+ kit_source: Path = KIT_SOURCE,
37
+ ) -> None:
38
+ """Create a new use-case project with the kit wired in."""
39
+ from voiceops.scaffold import init as scaffold
40
+
41
+ target = directory or Path(name)
42
+ try:
43
+ written = scaffold(target, name, organisation, kit_source)
44
+ except ConfigError as e:
45
+ typer.echo(f"error: {e}", err=True)
46
+ raise typer.Exit(2) from e
47
+ typer.echo(f"created {target} ({len(written)} files)")
48
+ typer.echo(f"next: cd {target} && uv sync && uv run voiceops gate")
49
+
50
+
51
+ @app.command()
52
+ def build(
53
+ check: bool = typer.Option(False, help="Fail if the committed build is stale (for CI)."),
54
+ config: Path = CONFIG,
55
+ ) -> None:
56
+ """Compose agent configs and tool schemas into the build directory."""
57
+ try:
58
+ cfg = load_config(config)
59
+ files = build_files(cfg)
60
+ except ConfigError as e:
61
+ typer.echo(f"error: {e}", err=True)
62
+ raise typer.Exit(2) from e
63
+ if check:
64
+ stale = stale_files(cfg, files)
65
+ if stale:
66
+ typer.echo(f"stale agent build files (run `voiceops build`): {stale}", err=True)
67
+ raise typer.Exit(1)
68
+ typer.echo("agent build up to date")
69
+ return
70
+ write(cfg, files)
71
+ typer.echo(f"wrote {len(files)} files to {cfg.path(cfg.agent.build_dir)}")
72
+
73
+
74
+ @app.command()
75
+ def push(
76
+ profile: str = typer.Option(..., help="Profile to push, as named in the profiles file."),
77
+ base_url: str = typer.Option(..., help="Public HTTPS URL of the project's tool API."),
78
+ dry_run: bool = typer.Option(False, help="Print the payloads, call nothing."),
79
+ config: Path = CONFIG,
80
+ ) -> None:
81
+ """Create or update the profile's tools and agent on the platform, idempotently."""
82
+ try:
83
+ cfg = load_config(config)
84
+ if dry_run:
85
+ typer.echo(push_dry_run(cfg, profile, base_url))
86
+ return
87
+ load_dotenv(cfg.root / ".env")
88
+ typer.echo(push_profile(cfg, profile, base_url))
89
+ except (ConfigError, PushError) as e:
90
+ typer.echo(f"error: {e}", err=True)
91
+ raise typer.Exit(2) from e
92
+
93
+
94
+ @app.command()
95
+ def gate(
96
+ level: str = typer.Option("all", help="tool | conversation | all"),
97
+ only: str = typer.Option("", help="Comma-separated scenario ids (writes partial.json)."),
98
+ smoke: bool = typer.Option(False, help="Only scenarios marked smoke."),
99
+ config: Path = CONFIG,
100
+ ) -> None:
101
+ """Run the release gate. Exit 0 = PASS, 1 = FAIL, 2 = ERRORED."""
102
+ from voiceops.evals import EXIT, gate_from_config, iter_failures
103
+
104
+ if level not in ("tool", "conversation", "all"):
105
+ typer.echo("error: --level must be tool, conversation or all", err=True)
106
+ raise typer.Exit(2)
107
+ try:
108
+ cfg = load_config(config)
109
+ load_dotenv(cfg.root / ".env")
110
+ report, out = gate_from_config(cfg, level, [s for s in only.split(",") if s], smoke)
111
+ except (ConfigError, ValueError) as e:
112
+ typer.echo(f"error: {e}", err=True)
113
+ raise typer.Exit(2) from e
114
+ status = report["gate"]["status"]
115
+ typer.echo(f"gate {status}: {report['gate']['summary']}")
116
+ for line in iter_failures(report):
117
+ typer.echo(f" {line}")
118
+ typer.echo(f"report: {out}")
119
+ raise typer.Exit(EXIT[status])
120
+
121
+
122
+ @app.command()
123
+ def check(
124
+ base_url: str = typer.Argument(None, help="Override [deploy] base_url."),
125
+ config: Path = CONFIG,
126
+ ) -> None:
127
+ """Run the declared deploy checks against a live deployment."""
128
+ from voiceops.deploy.run import run_checks
129
+
130
+ try:
131
+ cfg = load_config(config)
132
+ load_dotenv(cfg.root / ".env")
133
+ results = run_checks(cfg, base_url)
134
+ except ConfigError as e:
135
+ typer.echo(f"error: {e}", err=True)
136
+ raise typer.Exit(2) from e
137
+ for r in results:
138
+ typer.echo(r.line())
139
+ raise typer.Exit(0 if all(r.ok for r in results) else 1)
140
+
141
+
142
+ @app.command()
143
+ def call(
144
+ setup: bool = typer.Option(False, help="Import the Twilio number into ElevenLabs first."),
145
+ config: Path = CONFIG,
146
+ ) -> None:
147
+ """Place one test call to the single allow-listed number."""
148
+ from voiceops._imports import load_object
149
+ from voiceops.review import import_number, place_call
150
+
151
+ try:
152
+ cfg = load_config(config)
153
+ load_dotenv(cfg.root / ".env")
154
+ if setup:
155
+ typer.echo(f"phone number imported: {import_number(cfg)}")
156
+ return
157
+ placed = place_call(cfg)
158
+ except ConfigError as e:
159
+ typer.echo(f"error: {e}", err=True)
160
+ raise typer.Exit(2) from e
161
+ typer.echo(f"calling your allow-listed number ending {placed['to']}")
162
+ typer.echo(f"conversation_id: {placed['conversation_id']}")
163
+ if cfg.review and cfg.review.instructions:
164
+ typer.echo(load_object(cfg.review.instructions, cfg.root, "[review] instructions")())
165
+ typer.echo("afterwards: voiceops fetch <conversation_id>")
166
+
167
+
168
+ def _sheet_options(cfg):
169
+ from voiceops.evals import LeakDetector
170
+ from voiceops.review import SheetOptions
171
+
172
+ rv = cfg.review
173
+ vocab = cfg.path(cfg.evals.leak_vocabulary) if cfg.evals else None
174
+ return SheetOptions(
175
+ disclosure_markers=rv.disclosure_markers,
176
+ verify_tool=rv.verify_tool,
177
+ verified_status=rv.verified_status,
178
+ leak_context=rv.leak_context,
179
+ unbacked_promises=rv.unbacked_promises,
180
+ detector=LeakDetector.from_toml(vocab) if vocab and vocab.exists() else None,
181
+ )
182
+
183
+
184
+ def _write_sheet(cfg, conv: dict, title: str | None) -> Path:
185
+ from voiceops.review import render
186
+
187
+ out = cfg.path(cfg.review.sheets)
188
+ out.mkdir(parents=True, exist_ok=True)
189
+ path = out / f"{conv.get('conversation_id', 'call')}.md"
190
+ path.write_text(render(conv, _sheet_options(cfg), title))
191
+ return path
192
+
193
+
194
+ @app.command()
195
+ def fetch(
196
+ conversation_id: str,
197
+ title: str = typer.Option(None, help="Sheet title, e.g. 'Call 02 · Confirm and reschedule'."),
198
+ config: Path = CONFIG,
199
+ ) -> None:
200
+ """Save a call's raw conversation and audio locally, and render its review sheet."""
201
+ from voiceops.review import fetch_conversation, recordings_ignored
202
+
203
+ try:
204
+ cfg = load_config(config)
205
+ load_dotenv(cfg.root / ".env")
206
+ conv, folder = fetch_conversation(cfg, conversation_id)
207
+ except ConfigError as e:
208
+ typer.echo(f"error: {e}", err=True)
209
+ raise typer.Exit(2) from e
210
+ if recordings_ignored(folder) is False:
211
+ typer.echo(f"warning: {folder} is not gitignored; recordings must stay local", err=True)
212
+ typer.echo(f"saved raw conversation and audio in {folder}")
213
+ typer.echo(f"review sheet: {_write_sheet(cfg, conv, title)}")
214
+
215
+
216
+ @app.command()
217
+ def review(
218
+ conversation_json: Path,
219
+ title: str = typer.Option(None, help="Sheet title."),
220
+ config: Path = CONFIG,
221
+ ) -> None:
222
+ """Render a review sheet from a saved raw conversation (no network)."""
223
+ import json
224
+
225
+ try:
226
+ cfg = load_config(config)
227
+ if cfg.review is None:
228
+ raise ConfigError("voiceops.toml has no [review] section")
229
+ conv = json.loads(conversation_json.read_text())
230
+ except (ConfigError, OSError, ValueError) as e:
231
+ typer.echo(f"error: {e}", err=True)
232
+ raise typer.Exit(2) from e
233
+ typer.echo(f"review sheet: {_write_sheet(cfg, conv, title)}")
234
+
235
+
236
+ if __name__ == "__main__":
237
+ app()
@@ -0,0 +1,21 @@
1
+ """Agent configs from prompt fragments and profiles, with shipped safety defaults, tool schemas
2
+ from Pydantic models, a drift check and an idempotent push (PLAN §2, §4.4)."""
3
+
4
+ from voiceops.config.build import build, stale_files, write
5
+ from voiceops.config.fragments import SAFETY_SLOTS, kit_fragment
6
+ from voiceops.config.project import ConfigError, VoiceopsConfig, load_config
7
+ from voiceops.config.push import PushError, dry_run, push
8
+
9
+ __all__ = [
10
+ "SAFETY_SLOTS",
11
+ "ConfigError",
12
+ "PushError",
13
+ "VoiceopsConfig",
14
+ "build",
15
+ "dry_run",
16
+ "kit_fragment",
17
+ "load_config",
18
+ "push",
19
+ "stale_files",
20
+ "write",
21
+ ]
@@ -0,0 +1,118 @@
1
+ """Compose agent configs from fragments + profiles, and tool schemas from the request models.
2
+
3
+ Output is deterministic and committed, so every prompt or tool change shows up as a
4
+ reviewable diff; `check` fails on drift (for CI). Files written to [agent] build_dir:
5
+
6
+ tools.json webhook tool definitions
7
+ <profile>.json one agent config per profile
8
+ safety.json how each safety slot was satisfied in each profile
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ from voiceops.config.fragments import check_safety_config, compose
16
+ from voiceops.config.project import ConfigError, VoiceopsConfig
17
+ from voiceops.config.tools import Tools, load_tools, tool_schema
18
+
19
+
20
+ def _dump(obj) -> str:
21
+ return json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
22
+
23
+
24
+ def agent_config(key: str, p: dict, cfg: VoiceopsConfig, tools: Tools) -> tuple[dict, dict]:
25
+ a = cfg.agent
26
+ direction = a.directions.get(p["direction"])
27
+ if direction is None:
28
+ raise ConfigError(f"profile {key}: no [agent.directions.{p['direction']}]")
29
+ prompt, safety = compose(
30
+ key, p["fragments"], a, cfg.path(a.fragments), recording=p["record_voice"]
31
+ )
32
+ phone = direction.phone_audio
33
+ config = {
34
+ "name": p["name"],
35
+ "conversation_config": {
36
+ "agent": {
37
+ # A profile can override its direction's first message (e.g. no recording
38
+ # notice on a profile that doesn't record).
39
+ "first_message": p.get("first_message", direction.first_message),
40
+ "language": a.language,
41
+ "prompt": {
42
+ "llm": a.llm,
43
+ "temperature": a.temperature,
44
+ "prompt": prompt,
45
+ "tool_names": [n for n in tools if n not in direction.exclude_tools],
46
+ # The agent hangs up itself after the goodbye, instead of looping on
47
+ # goodbyes until the caller hangs up.
48
+ "built_in_tools": {
49
+ "end_call": {
50
+ "name": "end_call",
51
+ "description": a.end_call_description,
52
+ "params": {"system_tool_type": "end_call"},
53
+ }
54
+ },
55
+ },
56
+ },
57
+ "tts": {
58
+ "model_id": a.tts_model,
59
+ "voice_id": a.voice_id,
60
+ **({"agent_output_audio_format": "ulaw_8000"} if phone else {}),
61
+ },
62
+ **({"asr": {"user_input_audio_format": "ulaw_8000"}} if phone else {}),
63
+ },
64
+ "platform_settings": {
65
+ "privacy": {
66
+ "record_voice": p["record_voice"],
67
+ "zero_retention_mode": p["zero_retention_mode"],
68
+ }
69
+ },
70
+ }
71
+ return config, safety
72
+
73
+
74
+ # Platform constraint (ElevenLabs, 2026-10-17): agents whose language is English must use an
75
+ # English TTS model; the multilingual v2.5 models are refused at push time.
76
+ ENGLISH_TTS_MODELS = frozenset({"eleven_turbo_v2", "eleven_flash_v2"})
77
+
78
+
79
+ def check_platform_constraints(cfg: VoiceopsConfig) -> None:
80
+ a = cfg.agent
81
+ if a.language == "en" and a.tts_model not in ENGLISH_TTS_MODELS:
82
+ raise ConfigError(
83
+ f'[agent] language = "en" needs tts_model in {sorted(ENGLISH_TTS_MODELS)}'
84
+ f" (got {a.tts_model!r}); the platform refuses other models for English agents"
85
+ )
86
+
87
+
88
+ def build(cfg: VoiceopsConfig) -> dict[str, str]:
89
+ """Returns {filename: text} for the build directory. Raises ConfigError."""
90
+ check_safety_config(cfg.agent)
91
+ check_platform_constraints(cfg)
92
+ tools = load_tools(cfg.agent.tools, cfg.root)
93
+ for direction, d in cfg.agent.directions.items():
94
+ unknown = sorted(set(d.exclude_tools) - set(tools))
95
+ if unknown:
96
+ raise ConfigError(f"[agent.directions.{direction}] excludes unknown tools {unknown}")
97
+ profiles = json.loads(cfg.path(cfg.agent.profiles).read_text())
98
+ files = {"tools.json": _dump([tool_schema(n, tools, cfg) for n in tools])}
99
+ manifest = {}
100
+ for key, p in profiles.items():
101
+ if key.startswith("_"):
102
+ continue
103
+ files[f"{key}.json"], manifest[key] = agent_config(key, p, cfg, tools)
104
+ files[f"{key}.json"] = _dump(files[f"{key}.json"])
105
+ files["safety.json"] = _dump(manifest)
106
+ return files
107
+
108
+
109
+ def stale_files(cfg: VoiceopsConfig, files: dict[str, str]) -> list[str]:
110
+ out = cfg.path(cfg.agent.build_dir)
111
+ return [n for n, t in files.items() if not (out / n).exists() or (out / n).read_text() != t]
112
+
113
+
114
+ def write(cfg: VoiceopsConfig, files: dict[str, str]) -> None:
115
+ out = cfg.path(cfg.agent.build_dir)
116
+ out.mkdir(parents=True, exist_ok=True)
117
+ for name, text in files.items():
118
+ (out / name).write_text(text)
@@ -0,0 +1,88 @@
1
+ """Prompt composition with the kit's safety defaults (PLAN §4.4).
2
+
3
+ Each safety slot is satisfied in every profile in exactly one way:
4
+
5
+ reworded the project maps the slot to its own fragment ([agent.safety]); that
6
+ fragment must then be in the profile, or the build fails.
7
+ default the kit's wording (voiceops/config/safety/<slot>.md) is appended.
8
+ removed only with a reason in [agent.allow_remove]; recorded in safety.json.
9
+
10
+ So safety can be reworded, but never dropped silently.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+
19
+ from voiceops.config.project import AgentSection, ConfigError
20
+
21
+ SAFETY_SLOTS = (
22
+ "ai_disclosure",
23
+ "upset_caller",
24
+ "identity_before_detail",
25
+ "third_party_voicemail",
26
+ "human_on_request",
27
+ "no_unbacked_promises",
28
+ "closing",
29
+ )
30
+ _VAR = re.compile(r"\{\{(\w+)\}\}")
31
+
32
+
33
+ def kit_fragment(slot: str) -> str:
34
+ return files("voiceops.config").joinpath("safety", f"{slot}.md").read_text().strip()
35
+
36
+
37
+ def _fill(slot: str, text: str, values: dict[str, str]) -> str:
38
+ missing = sorted({v for v in _VAR.findall(text) if v not in values})
39
+ if missing:
40
+ raise ConfigError(
41
+ f"kit safety fragment {slot!r} needs [agent.vars] {', '.join(missing)}"
42
+ f' (or reword it with [agent.safety] {slot} = "<your fragment>")'
43
+ )
44
+ return _VAR.sub(lambda m: values[m.group(1)], text)
45
+
46
+
47
+ def check_safety_config(agent: AgentSection) -> None:
48
+ unknown = sorted((set(agent.safety) | set(agent.allow_remove)) - set(SAFETY_SLOTS))
49
+ if unknown:
50
+ raise ConfigError(f"unknown safety slots {unknown} (known: {', '.join(SAFETY_SLOTS)})")
51
+ both = sorted(set(agent.safety) & set(agent.allow_remove))
52
+ if both:
53
+ raise ConfigError(f"safety slots both reworded and removed: {both}")
54
+ blank = sorted(k for k, why in agent.allow_remove.items() if not why.strip())
55
+ if blank:
56
+ raise ConfigError(f"allow_remove needs a reason for: {blank}")
57
+
58
+
59
+ def compose(
60
+ key: str, fragments: list[str], agent: AgentSection, fragment_dir: Path, recording: bool
61
+ ) -> tuple[str, dict[str, str]]:
62
+ """Returns (prompt, how each safety slot was satisfied) for one profile."""
63
+ missing = [f for f in agent.required_fragments if f not in fragments]
64
+ if missing:
65
+ raise ConfigError(f"profile {key} is missing required fragments: {missing}")
66
+ parts = [(fragment_dir / f"{f}.md").read_text().strip() for f in fragments]
67
+ values = {"recording_notice": ", and that the call is recorded" if recording else ""}
68
+ values |= agent.vars
69
+ manifest: dict[str, str] = {}
70
+ for slot in SAFETY_SLOTS:
71
+ if slot in agent.allow_remove:
72
+ manifest[slot] = f"removed: {agent.allow_remove[slot]}"
73
+ elif slot in agent.safety:
74
+ # One fragment, or alternatives (e.g. a disclosure with and without a recording
75
+ # notice): the profile must include at least one of them.
76
+ options = agent.safety[slot]
77
+ options = [options] if isinstance(options, str) else options
78
+ present = [f for f in options if f in fragments]
79
+ if not present:
80
+ raise ConfigError(
81
+ f"profile {key} drops safety slot {slot!r}: none of {options} is in the"
82
+ " profile (add one, or [agent.allow_remove] with a reason)"
83
+ )
84
+ manifest[slot] = "project: " + ", ".join(present)
85
+ else:
86
+ parts.append(_fill(slot, kit_fragment(slot), values))
87
+ manifest[slot] = "kit default"
88
+ return "\n\n".join(parts) + "\n", manifest
@@ -0,0 +1,159 @@
1
+ """`voiceops.toml`: a project's contract. Every command reads the same declared setup.
2
+
3
+ Relative paths resolve from the directory holding the file. Unknown keys in the
4
+ sections read here are errors, so a typo can't silently fall back to a default.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ from pydantic import BaseModel, ConfigDict, ValidationError
14
+
15
+ from voiceops.deploy.spec import Check, SessionSpec
16
+
17
+ CONFIG_FILE = "voiceops.toml"
18
+
19
+
20
+ class ConfigError(ValueError):
21
+ pass
22
+
23
+
24
+ class _Strict(BaseModel):
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+
28
+ class ProjectSection(_Strict):
29
+ name: str
30
+ locales: list[str] = []
31
+
32
+
33
+ class Direction(_Strict):
34
+ first_message: str
35
+ phone_audio: bool = False # telephone codec (ulaw_8000) in and out, no resampling
36
+ exclude_tools: list[str] = []
37
+
38
+
39
+ class AgentSection(_Strict):
40
+ fragments: Path = Path("agent/fragments")
41
+ profiles: Path = Path("agent/profiles.json")
42
+ build_dir: Path = Path("agent/build")
43
+ tools: str # "package.module:ATTR", a dict of name -> (Pydantic model, description)
44
+ language: str
45
+ llm: str
46
+ temperature: float = 0.2
47
+ voice_id: str
48
+ tts_model: str = "eleven_flash_v2_5"
49
+ tool_timeout_secs: int = 10
50
+ end_call_description: str = "End the call after logging the outcome and saying goodbye."
51
+ directions: dict[str, Direction]
52
+ # Kit safety slot -> the project fragment that rewords it, or a list of alternatives
53
+ # (each profile must include at least one). See voiceops.config.fragments.
54
+ safety: dict[str, str | list[str]] = {}
55
+ # Kit safety slot -> reason for dropping it. Recorded in the build's safety.json.
56
+ allow_remove: dict[str, str] = {}
57
+ # Project fragments every profile must include (domain safety rules).
58
+ required_fragments: list[str] = []
59
+ # Values for {{placeholders}} in the kit's default safety fragments.
60
+ vars: dict[str, str] = {}
61
+
62
+
63
+ class GuardSection(_Strict):
64
+ secret_header: str = "X-Tool-Secret"
65
+ token_header: str = "X-Call-Token"
66
+ token_variable: str = "secret__call_token" # platform dynamic variable holding the token
67
+
68
+
69
+ class PushSection(_Strict):
70
+ secret_name: str # name of the tool secret stored on the platform
71
+ state: Path = Path("agent/state.json")
72
+ api_base: str = "https://api.elevenlabs.io"
73
+ api_key_env: str = "ELEVENLABS_API_KEY"
74
+ tool_secret_env: str = "TOOL_SECRET"
75
+
76
+
77
+ class EvalsSection(_Strict):
78
+ scenarios: str # "package.module:ATTR", a list of voiceops.evals.Scenario
79
+ # "package.module:ATTR", a context manager factory: run_id -> run(scenario_id) -> checks
80
+ tool_runner: str | None = None
81
+ # "package.module:ATTR", a factory: config -> voiceops.evals.ConversationSetup
82
+ conversation: str | None = None
83
+ leak_vocabulary: Path = Path("evals/leak_vocabulary.toml")
84
+ known_answers: Path = Path("evals/known_answer")
85
+ max_false_alarms: int = 2
86
+ # Scenario checks that, when False, count as a privacy leak in the report.
87
+ leak_checks: list[str] = ["nothing_disclosed", "no_privacy_leak"]
88
+ results: Path = Path("evals/results")
89
+
90
+
91
+ class DeploySection(_Strict):
92
+ base_url: str # or "env:NAME"
93
+ timeout_s: float = 30
94
+ checks: list[Check]
95
+
96
+
97
+ class ReviewSection(_Strict):
98
+ allowlist_env: str = "DEMO_PHONE_ALLOWLIST" # exactly one E.164 number may be dialled
99
+ profile: str # the built profile whose pushed agent places test calls
100
+ api_url: str # the project's API, or "env:NAME"
101
+ session: SessionSpec # opens the call's session; the agent gets its token
102
+ instructions: str | None = None # "package.module:ATTR" -> str printed before a call
103
+ recordings: Path = Path("recordings") # raw conversations and audio (keep gitignored)
104
+ sheets: Path = Path("review") # rendered review sheets
105
+ disclosure_markers: list[str] = ["ai assistant", "artificial intelligence"]
106
+ verify_tool: str = "verify_identity"
107
+ verified_status: str = "verified"
108
+ leak_context: str = "subject"
109
+ # Phrases that promise something no tool does (e.g. "by email"): flagged for review.
110
+ unbacked_promises: list[str] = []
111
+
112
+
113
+ class VoiceopsConfig(BaseModel):
114
+ model_config = ConfigDict(extra="ignore") # sections for other modules ([ledger], ...)
115
+
116
+ project: ProjectSection
117
+ agent: AgentSection
118
+ guard: GuardSection = GuardSection()
119
+ push: PushSection | None = None
120
+ evals: EvalsSection | None = None
121
+ deploy: DeploySection | None = None
122
+ review: ReviewSection | None = None
123
+ root: Path = Path(".")
124
+
125
+ def path(self, p: Path) -> Path:
126
+ return p if p.is_absolute() else self.root / p
127
+
128
+
129
+ def resolve_env(value: str) -> str:
130
+ """ "env:NAME" -> the environment variable (must be set); anything else as is."""
131
+ if isinstance(value, str) and value.startswith("env:"):
132
+ name = value[4:]
133
+ v = os.environ.get(name, "").strip()
134
+ if not v:
135
+ raise ConfigError(f"{name} is not set (see .env.example)")
136
+ return v
137
+ return value
138
+
139
+
140
+ def resolve_env_tree(obj):
141
+ if isinstance(obj, dict):
142
+ return {k: resolve_env_tree(v) for k, v in obj.items()}
143
+ if isinstance(obj, list):
144
+ return [resolve_env_tree(v) for v in obj]
145
+ return resolve_env(obj)
146
+
147
+
148
+ def load_config(path: str | Path = CONFIG_FILE) -> VoiceopsConfig:
149
+ path = Path(path)
150
+ try:
151
+ data = tomllib.loads(path.read_text())
152
+ except FileNotFoundError as e:
153
+ raise ConfigError(f"{path} not found") from e
154
+ except tomllib.TOMLDecodeError as e:
155
+ raise ConfigError(f"{path}: {e}") from e
156
+ try:
157
+ return VoiceopsConfig(**data, root=path.resolve().parent)
158
+ except ValidationError as e:
159
+ raise ConfigError(f"{path}: {e}") from e