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.
- voiceops/__init__.py +3 -0
- voiceops/_imports.py +28 -0
- voiceops/cli.py +237 -0
- voiceops/config/__init__.py +21 -0
- voiceops/config/build.py +118 -0
- voiceops/config/fragments.py +88 -0
- voiceops/config/project.py +159 -0
- voiceops/config/push.py +140 -0
- voiceops/config/safety/ai_disclosure.md +3 -0
- voiceops/config/safety/closing.md +2 -0
- voiceops/config/safety/human_on_request.md +4 -0
- voiceops/config/safety/identity_before_detail.md +5 -0
- voiceops/config/safety/no_unbacked_promises.md +4 -0
- voiceops/config/safety/third_party_voicemail.md +4 -0
- voiceops/config/safety/upset_caller.md +5 -0
- voiceops/config/tools.py +72 -0
- voiceops/deploy/__init__.py +6 -0
- voiceops/deploy/run.py +128 -0
- voiceops/deploy/spec.py +65 -0
- voiceops/evals/__init__.py +31 -0
- voiceops/evals/elevenlabs.py +168 -0
- voiceops/evals/gate.py +189 -0
- voiceops/evals/known_answer.py +73 -0
- voiceops/evals/leak.py +104 -0
- voiceops/evals/scenarios.py +30 -0
- voiceops/guard/__init__.py +38 -0
- voiceops/guard/fastapi.py +88 -0
- voiceops/guard/logs.py +25 -0
- voiceops/guard/ratelimit.py +31 -0
- voiceops/guard/secrets.py +47 -0
- voiceops/guard/tokens.py +186 -0
- voiceops/review/__init__.py +21 -0
- voiceops/review/calls.py +169 -0
- voiceops/review/sheet.py +205 -0
- voiceops/scaffold/__init__.py +65 -0
- voiceops/scaffold/template/Dockerfile +17 -0
- voiceops/scaffold/template/README.md +36 -0
- voiceops/scaffold/template/agent/fragments/00_role.md +4 -0
- voiceops/scaffold/template/agent/fragments/40_no_decisions.md +3 -0
- voiceops/scaffold/template/agent/fragments/50_case.md +3 -0
- voiceops/scaffold/template/agent/profiles.json +25 -0
- voiceops/scaffold/template/app/__init__.py +0 -0
- voiceops/scaffold/template/app/api.py +112 -0
- voiceops/scaffold/template/app/models.py +39 -0
- voiceops/scaffold/template/app/service.py +181 -0
- voiceops/scaffold/template/dot_dockerignore +7 -0
- voiceops/scaffold/template/dot_env.example +12 -0
- voiceops/scaffold/template/dot_github/workflows/ci.yml +21 -0
- voiceops/scaffold/template/dot_gitignore +7 -0
- voiceops/scaffold/template/evals/__init__.py +0 -0
- voiceops/scaffold/template/evals/known_answer/cases.json +18 -0
- voiceops/scaffold/template/evals/leak_vocabulary.toml +14 -0
- voiceops/scaffold/template/evals/runner.py +149 -0
- voiceops/scaffold/template/evals/scenarios.py +32 -0
- voiceops/scaffold/template/pyproject.toml +26 -0
- voiceops/scaffold/template/tests/test_api.py +82 -0
- voiceops/scaffold/template/voiceops.toml +97 -0
- voiceops-0.1.3.dist-info/METADATA +35 -0
- voiceops-0.1.3.dist-info/RECORD +62 -0
- voiceops-0.1.3.dist-info/WHEEL +4 -0
- voiceops-0.1.3.dist-info/entry_points.txt +2 -0
- voiceops-0.1.3.dist-info/licenses/LICENSE +21 -0
voiceops/__init__.py
ADDED
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
|
+
]
|
voiceops/config/build.py
ADDED
|
@@ -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
|