voiceops 0.1.3__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 (72) hide show
  1. voiceops-0.1.3/.gitignore +11 -0
  2. voiceops-0.1.3/LICENSE +21 -0
  3. voiceops-0.1.3/PKG-INFO +35 -0
  4. voiceops-0.1.3/README.md +13 -0
  5. voiceops-0.1.3/pyproject.toml +28 -0
  6. voiceops-0.1.3/src/voiceops/__init__.py +3 -0
  7. voiceops-0.1.3/src/voiceops/_imports.py +28 -0
  8. voiceops-0.1.3/src/voiceops/cli.py +237 -0
  9. voiceops-0.1.3/src/voiceops/config/__init__.py +21 -0
  10. voiceops-0.1.3/src/voiceops/config/build.py +118 -0
  11. voiceops-0.1.3/src/voiceops/config/fragments.py +88 -0
  12. voiceops-0.1.3/src/voiceops/config/project.py +159 -0
  13. voiceops-0.1.3/src/voiceops/config/push.py +140 -0
  14. voiceops-0.1.3/src/voiceops/config/safety/ai_disclosure.md +3 -0
  15. voiceops-0.1.3/src/voiceops/config/safety/closing.md +2 -0
  16. voiceops-0.1.3/src/voiceops/config/safety/human_on_request.md +4 -0
  17. voiceops-0.1.3/src/voiceops/config/safety/identity_before_detail.md +5 -0
  18. voiceops-0.1.3/src/voiceops/config/safety/no_unbacked_promises.md +4 -0
  19. voiceops-0.1.3/src/voiceops/config/safety/third_party_voicemail.md +4 -0
  20. voiceops-0.1.3/src/voiceops/config/safety/upset_caller.md +5 -0
  21. voiceops-0.1.3/src/voiceops/config/tools.py +72 -0
  22. voiceops-0.1.3/src/voiceops/deploy/__init__.py +6 -0
  23. voiceops-0.1.3/src/voiceops/deploy/run.py +128 -0
  24. voiceops-0.1.3/src/voiceops/deploy/spec.py +65 -0
  25. voiceops-0.1.3/src/voiceops/evals/__init__.py +31 -0
  26. voiceops-0.1.3/src/voiceops/evals/elevenlabs.py +168 -0
  27. voiceops-0.1.3/src/voiceops/evals/gate.py +189 -0
  28. voiceops-0.1.3/src/voiceops/evals/known_answer.py +73 -0
  29. voiceops-0.1.3/src/voiceops/evals/leak.py +104 -0
  30. voiceops-0.1.3/src/voiceops/evals/scenarios.py +30 -0
  31. voiceops-0.1.3/src/voiceops/guard/__init__.py +38 -0
  32. voiceops-0.1.3/src/voiceops/guard/fastapi.py +88 -0
  33. voiceops-0.1.3/src/voiceops/guard/logs.py +25 -0
  34. voiceops-0.1.3/src/voiceops/guard/ratelimit.py +31 -0
  35. voiceops-0.1.3/src/voiceops/guard/secrets.py +47 -0
  36. voiceops-0.1.3/src/voiceops/guard/tokens.py +186 -0
  37. voiceops-0.1.3/src/voiceops/review/__init__.py +21 -0
  38. voiceops-0.1.3/src/voiceops/review/calls.py +169 -0
  39. voiceops-0.1.3/src/voiceops/review/sheet.py +205 -0
  40. voiceops-0.1.3/src/voiceops/scaffold/__init__.py +65 -0
  41. voiceops-0.1.3/src/voiceops/scaffold/template/Dockerfile +17 -0
  42. voiceops-0.1.3/src/voiceops/scaffold/template/README.md +36 -0
  43. voiceops-0.1.3/src/voiceops/scaffold/template/agent/fragments/00_role.md +4 -0
  44. voiceops-0.1.3/src/voiceops/scaffold/template/agent/fragments/40_no_decisions.md +3 -0
  45. voiceops-0.1.3/src/voiceops/scaffold/template/agent/fragments/50_case.md +3 -0
  46. voiceops-0.1.3/src/voiceops/scaffold/template/agent/profiles.json +25 -0
  47. voiceops-0.1.3/src/voiceops/scaffold/template/app/__init__.py +0 -0
  48. voiceops-0.1.3/src/voiceops/scaffold/template/app/api.py +112 -0
  49. voiceops-0.1.3/src/voiceops/scaffold/template/app/models.py +39 -0
  50. voiceops-0.1.3/src/voiceops/scaffold/template/app/service.py +181 -0
  51. voiceops-0.1.3/src/voiceops/scaffold/template/dot_dockerignore +7 -0
  52. voiceops-0.1.3/src/voiceops/scaffold/template/dot_env.example +12 -0
  53. voiceops-0.1.3/src/voiceops/scaffold/template/dot_github/workflows/ci.yml +21 -0
  54. voiceops-0.1.3/src/voiceops/scaffold/template/dot_gitignore +7 -0
  55. voiceops-0.1.3/src/voiceops/scaffold/template/evals/__init__.py +0 -0
  56. voiceops-0.1.3/src/voiceops/scaffold/template/evals/known_answer/cases.json +18 -0
  57. voiceops-0.1.3/src/voiceops/scaffold/template/evals/leak_vocabulary.toml +14 -0
  58. voiceops-0.1.3/src/voiceops/scaffold/template/evals/runner.py +149 -0
  59. voiceops-0.1.3/src/voiceops/scaffold/template/evals/scenarios.py +32 -0
  60. voiceops-0.1.3/src/voiceops/scaffold/template/pyproject.toml +26 -0
  61. voiceops-0.1.3/src/voiceops/scaffold/template/tests/test_api.py +82 -0
  62. voiceops-0.1.3/src/voiceops/scaffold/template/voiceops.toml +97 -0
  63. voiceops-0.1.3/tests/conftest.py +213 -0
  64. voiceops-0.1.3/tests/test_config_build.py +186 -0
  65. voiceops-0.1.3/tests/test_config_push.py +154 -0
  66. voiceops-0.1.3/tests/test_deploy.py +172 -0
  67. voiceops-0.1.3/tests/test_evals.py +306 -0
  68. voiceops-0.1.3/tests/test_guard.py +143 -0
  69. voiceops-0.1.3/tests/test_guard_tokens.py +78 -0
  70. voiceops-0.1.3/tests/test_package.py +11 -0
  71. voiceops-0.1.3/tests/test_review.py +223 -0
  72. voiceops-0.1.3/tests/test_scaffold.py +130 -0
@@ -0,0 +1,11 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ .DS_Store
8
+ dist/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ recordings/
voiceops-0.1.3/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Digvijay Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.5
2
+ Name: voiceops
3
+ Version: 0.1.3
4
+ Summary: Deployment discipline for voice agents: safe defaults, a release gate, an audit ledger, deploy checks and call review.
5
+ Project-URL: Repository, https://github.com/digvijaysingh-ai/voiceops
6
+ Project-URL: Documentation, https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md
7
+ Author: Digvijay Singh
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: pydantic>=2.7
13
+ Requires-Dist: python-dotenv>=1.0
14
+ Requires-Dist: typer>=0.12
15
+ Requires-Dist: voiceledger>=0.1.3
16
+ Provides-Extra: fastapi
17
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
18
+ Provides-Extra: postgres
19
+ Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
20
+ Requires-Dist: voiceledger[postgres]; extra == 'postgres'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # voiceops
24
+
25
+ Deployment discipline for voice agents, as a kit: safety defaults, a release gate, a verifiable audit ledger, deploy checks and a real-call review loop.
26
+
27
+ ```bash
28
+ uvx voiceops init claims-line --organisation "Example Insurance"
29
+ cd claims-line && uv sync
30
+ uv run voiceops gate
31
+ ```
32
+
33
+ Commands: `init`, `build [--check]`, `push`, `gate`, `check`, `call`, `fetch`, `review`. Extras: `voiceops[fastapi]` (guard dependencies), `voiceops[postgres]` (token store, ledger storage).
34
+
35
+ Guide: [https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md](https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md). MIT licence.
@@ -0,0 +1,13 @@
1
+ # voiceops
2
+
3
+ Deployment discipline for voice agents, as a kit: safety defaults, a release gate, a verifiable audit ledger, deploy checks and a real-call review loop.
4
+
5
+ ```bash
6
+ uvx voiceops init claims-line --organisation "Example Insurance"
7
+ cd claims-line && uv sync
8
+ uv run voiceops gate
9
+ ```
10
+
11
+ Commands: `init`, `build [--check]`, `push`, `gate`, `check`, `call`, `fetch`, `review`. Extras: `voiceops[fastapi]` (guard dependencies), `voiceops[postgres]` (token store, ledger storage).
12
+
13
+ Guide: [https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md](https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md). MIT licence.
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "voiceops"
3
+ version = "0.1.3"
4
+ description = "Deployment discipline for voice agents: safe defaults, a release gate, an audit ledger, deploy checks and call review."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Digvijay Singh" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = ["voiceledger>=0.1.3", "pydantic>=2.7", "httpx>=0.27", "typer>=0.12", "python-dotenv>=1.0"]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/digvijaysingh-ai/voiceops"
14
+ Documentation = "https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md"
15
+
16
+ [project.optional-dependencies]
17
+ fastapi = ["fastapi>=0.110"]
18
+ postgres = ["psycopg[binary]>=3.2", "voiceledger[postgres]"]
19
+
20
+ [project.scripts]
21
+ voiceops = "voiceops.cli:app"
22
+
23
+ [tool.uv.sources]
24
+ voiceledger = { workspace = true }
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
@@ -0,0 +1,3 @@
1
+ """voiceops: deployment discipline for voice agents (see docs/PLAN.md)."""
2
+
3
+ __version__ = "0.1.3"
@@ -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))
@@ -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