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.
- voiceops-0.1.3/.gitignore +11 -0
- voiceops-0.1.3/LICENSE +21 -0
- voiceops-0.1.3/PKG-INFO +35 -0
- voiceops-0.1.3/README.md +13 -0
- voiceops-0.1.3/pyproject.toml +28 -0
- voiceops-0.1.3/src/voiceops/__init__.py +3 -0
- voiceops-0.1.3/src/voiceops/_imports.py +28 -0
- voiceops-0.1.3/src/voiceops/cli.py +237 -0
- voiceops-0.1.3/src/voiceops/config/__init__.py +21 -0
- voiceops-0.1.3/src/voiceops/config/build.py +118 -0
- voiceops-0.1.3/src/voiceops/config/fragments.py +88 -0
- voiceops-0.1.3/src/voiceops/config/project.py +159 -0
- voiceops-0.1.3/src/voiceops/config/push.py +140 -0
- voiceops-0.1.3/src/voiceops/config/safety/ai_disclosure.md +3 -0
- voiceops-0.1.3/src/voiceops/config/safety/closing.md +2 -0
- voiceops-0.1.3/src/voiceops/config/safety/human_on_request.md +4 -0
- voiceops-0.1.3/src/voiceops/config/safety/identity_before_detail.md +5 -0
- voiceops-0.1.3/src/voiceops/config/safety/no_unbacked_promises.md +4 -0
- voiceops-0.1.3/src/voiceops/config/safety/third_party_voicemail.md +4 -0
- voiceops-0.1.3/src/voiceops/config/safety/upset_caller.md +5 -0
- voiceops-0.1.3/src/voiceops/config/tools.py +72 -0
- voiceops-0.1.3/src/voiceops/deploy/__init__.py +6 -0
- voiceops-0.1.3/src/voiceops/deploy/run.py +128 -0
- voiceops-0.1.3/src/voiceops/deploy/spec.py +65 -0
- voiceops-0.1.3/src/voiceops/evals/__init__.py +31 -0
- voiceops-0.1.3/src/voiceops/evals/elevenlabs.py +168 -0
- voiceops-0.1.3/src/voiceops/evals/gate.py +189 -0
- voiceops-0.1.3/src/voiceops/evals/known_answer.py +73 -0
- voiceops-0.1.3/src/voiceops/evals/leak.py +104 -0
- voiceops-0.1.3/src/voiceops/evals/scenarios.py +30 -0
- voiceops-0.1.3/src/voiceops/guard/__init__.py +38 -0
- voiceops-0.1.3/src/voiceops/guard/fastapi.py +88 -0
- voiceops-0.1.3/src/voiceops/guard/logs.py +25 -0
- voiceops-0.1.3/src/voiceops/guard/ratelimit.py +31 -0
- voiceops-0.1.3/src/voiceops/guard/secrets.py +47 -0
- voiceops-0.1.3/src/voiceops/guard/tokens.py +186 -0
- voiceops-0.1.3/src/voiceops/review/__init__.py +21 -0
- voiceops-0.1.3/src/voiceops/review/calls.py +169 -0
- voiceops-0.1.3/src/voiceops/review/sheet.py +205 -0
- voiceops-0.1.3/src/voiceops/scaffold/__init__.py +65 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/Dockerfile +17 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/README.md +36 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/agent/fragments/00_role.md +4 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/agent/fragments/40_no_decisions.md +3 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/agent/fragments/50_case.md +3 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/agent/profiles.json +25 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/app/__init__.py +0 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/app/api.py +112 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/app/models.py +39 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/app/service.py +181 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/dot_dockerignore +7 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/dot_env.example +12 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/dot_github/workflows/ci.yml +21 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/dot_gitignore +7 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/evals/__init__.py +0 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/evals/known_answer/cases.json +18 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/evals/leak_vocabulary.toml +14 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/evals/runner.py +149 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/evals/scenarios.py +32 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/pyproject.toml +26 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/tests/test_api.py +82 -0
- voiceops-0.1.3/src/voiceops/scaffold/template/voiceops.toml +97 -0
- voiceops-0.1.3/tests/conftest.py +213 -0
- voiceops-0.1.3/tests/test_config_build.py +186 -0
- voiceops-0.1.3/tests/test_config_push.py +154 -0
- voiceops-0.1.3/tests/test_deploy.py +172 -0
- voiceops-0.1.3/tests/test_evals.py +306 -0
- voiceops-0.1.3/tests/test_guard.py +143 -0
- voiceops-0.1.3/tests/test_guard_tokens.py +78 -0
- voiceops-0.1.3/tests/test_package.py +11 -0
- voiceops-0.1.3/tests/test_review.py +223 -0
- voiceops-0.1.3/tests/test_scaffold.py +130 -0
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.
|
voiceops-0.1.3/PKG-INFO
ADDED
|
@@ -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.
|
voiceops-0.1.3/README.md
ADDED
|
@@ -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,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
|