interaction-harness 0.1.0__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.
- interaction_harness/__init__.py +1 -0
- interaction_harness/__main__.py +3 -0
- interaction_harness/cli.py +140 -0
- interaction_harness/config.py +210 -0
- interaction_harness/doctor.py +171 -0
- interaction_harness/llm.py +174 -0
- interaction_harness/prompts.py +29 -0
- interaction_harness/selftest.py +289 -0
- interaction_harness/server.py +106 -0
- interaction_harness/session.py +848 -0
- interaction_harness/speech/__init__.py +93 -0
- interaction_harness/speech/assets/SILERO_VAD_LICENSE +21 -0
- interaction_harness/speech/assets/silero_vad.onnx +0 -0
- interaction_harness/speech/base.py +142 -0
- interaction_harness/speech/endpoint.py +112 -0
- interaction_harness/speech/mlx.py +143 -0
- interaction_harness/speech/onnx.py +88 -0
- interaction_harness/speech/silero.py +53 -0
- interaction_harness/speech/text.py +75 -0
- interaction_harness/static/app.js +277 -0
- interaction_harness/static/index.html +97 -0
- interaction_harness/static/mic-worklet.js +37 -0
- interaction_harness/static/style.css +151 -0
- interaction_harness/tools/__init__.py +5 -0
- interaction_harness/tools/builtin.py +161 -0
- interaction_harness/tools/registry.py +155 -0
- interaction_harness/tools/web.py +359 -0
- interaction_harness-0.1.0.dist-info/METADATA +269 -0
- interaction_harness-0.1.0.dist-info/RECORD +33 -0
- interaction_harness-0.1.0.dist-info/WHEEL +4 -0
- interaction_harness-0.1.0.dist-info/entry_points.txt +2 -0
- interaction_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
- interaction_harness-0.1.0.dist-info/licenses/src/interaction_harness/speech/assets/SILERO_VAD_LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""interaction-harness command line.
|
|
2
|
+
|
|
3
|
+
interaction-harness serve the dashboard on http://localhost:8765
|
|
4
|
+
interaction-harness doctor check all four model roles and report each one's latency
|
|
5
|
+
interaction-harness selftest talk to the agent with synthesized speech and check every behaviour
|
|
6
|
+
interaction-harness warmup download and load the in-process speech models, then exit
|
|
7
|
+
|
|
8
|
+
Every subcommand takes the same settings flags; anything not given comes from the environment, .env, or
|
|
9
|
+
interaction-harness.toml (see .env.example). API keys come from the environment or files, never from flags,
|
|
10
|
+
so they stay out of shell history.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import webbrowser
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .config import LOCAL_BACKENDS, THINKING_MODES, Settings, load_settings
|
|
23
|
+
|
|
24
|
+
ROLES = ("foreground", "background", "asr", "tts")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_settings_flags(p: argparse.ArgumentParser) -> None:
|
|
28
|
+
p.add_argument("--config", metavar="FILE", help="TOML settings (default: ./interaction-harness.toml if present)")
|
|
29
|
+
g = p.add_argument_group("model roles", "each role is any OpenAI-compatible endpoint; ASR/TTS run "
|
|
30
|
+
"in-process when their base URL is blank or 'local'")
|
|
31
|
+
for role in ROLES:
|
|
32
|
+
g.add_argument(f"--{role}-base-url", metavar="URL")
|
|
33
|
+
g.add_argument(f"--{role}-model", metavar="NAME")
|
|
34
|
+
g.add_argument("--foreground-thinking", choices=THINKING_MODES, help="how to switch thinking on that server")
|
|
35
|
+
g.add_argument("--background-thinking", choices=THINKING_MODES)
|
|
36
|
+
s = p.add_argument_group("speech")
|
|
37
|
+
s.add_argument("--tts-voice", metavar="NAME")
|
|
38
|
+
s.add_argument("--backend", choices=LOCAL_BACKENDS, help="in-process speech (default auto: mlx on Apple "
|
|
39
|
+
"Silicon, onnx elsewhere)")
|
|
40
|
+
s.add_argument("--device", choices=("cpu", "cuda"), help="onnx backend device (default cpu)")
|
|
41
|
+
p.add_argument("--tools", action="append", metavar="FILE", help="Python file of @tool functions (repeatable)")
|
|
42
|
+
p.add_argument("--host", help="bind address (default 127.0.0.1)")
|
|
43
|
+
p.add_argument("--port", type=int, help="port (default 8765)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _flags(args: argparse.Namespace) -> dict:
|
|
47
|
+
keys = [f"{r}_{f}" for r in ROLES for f in ("base_url", "model")] + [
|
|
48
|
+
"foreground_thinking", "background_thinking", "tts_voice", "backend", "device", "tools", "host", "port",
|
|
49
|
+
"ssl_certfile", "ssl_keyfile"]
|
|
50
|
+
return {k.upper(): getattr(args, k, None) for k in keys}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _settings(args: argparse.Namespace, require_foreground: bool = True) -> Settings:
|
|
54
|
+
try:
|
|
55
|
+
return load_settings(_flags(args), config_path=args.config, require_foreground=require_foreground)
|
|
56
|
+
except (ValueError, FileNotFoundError) as e:
|
|
57
|
+
sys.exit(f"interaction-harness: {e}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _local_url(settings: Settings) -> str:
|
|
61
|
+
host = "localhost" if settings.host in ("127.0.0.1", "0.0.0.0", "::", "localhost") else settings.host
|
|
62
|
+
return f"{'https' if settings.ssl_certfile else 'http'}://{host}:{settings.port}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
66
|
+
import httpx
|
|
67
|
+
import uvicorn
|
|
68
|
+
|
|
69
|
+
from .server import create_app
|
|
70
|
+
settings = _settings(args)
|
|
71
|
+
url = _local_url(settings)
|
|
72
|
+
print(f"interaction-harness {__version__} -> {url}", flush=True)
|
|
73
|
+
if settings.host not in ("127.0.0.1", "localhost", "::1") and not settings.ssl_certfile:
|
|
74
|
+
print("note: browsers only allow the microphone on localhost or HTTPS. From another machine, use an SSH "
|
|
75
|
+
"tunnel (ssh -L 8765:localhost:8765 this-host) or pass --ssl-certfile/--ssl-keyfile.", flush=True)
|
|
76
|
+
|
|
77
|
+
if args.open:
|
|
78
|
+
def open_when_ready():
|
|
79
|
+
for _ in range(600):
|
|
80
|
+
try:
|
|
81
|
+
if httpx.get(f"{url}/api/status", timeout=1, verify=False).status_code == 200:
|
|
82
|
+
webbrowser.open(url)
|
|
83
|
+
return
|
|
84
|
+
except httpx.HTTPError:
|
|
85
|
+
pass
|
|
86
|
+
time.sleep(1)
|
|
87
|
+
threading.Thread(target=open_when_ready, daemon=True).start()
|
|
88
|
+
|
|
89
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port, log_level="warning",
|
|
90
|
+
ssl_certfile=settings.ssl_certfile, ssl_keyfile=settings.ssl_keyfile)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
94
|
+
from .doctor import run
|
|
95
|
+
sys.exit(asyncio.run(run(_settings(args))))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_selftest(args: argparse.Namespace) -> None:
|
|
99
|
+
from .selftest import run
|
|
100
|
+
sys.exit(asyncio.run(run(_settings(args), url=args.url)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_warmup(args: argparse.Namespace) -> None:
|
|
104
|
+
from .doctor import warmup
|
|
105
|
+
sys.exit(asyncio.run(warmup(_settings(args, require_foreground=False))))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> None:
|
|
109
|
+
parser = argparse.ArgumentParser(prog="interaction-harness", description=__doc__.split("\n\n")[0],
|
|
110
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
111
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
112
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
113
|
+
|
|
114
|
+
p = sub.add_parser("serve", help="run the dashboard and the voice session server")
|
|
115
|
+
_add_settings_flags(p)
|
|
116
|
+
p.add_argument("--ssl-certfile", metavar="FILE", help="serve HTTPS so a remote browser may use the mic")
|
|
117
|
+
p.add_argument("--ssl-keyfile", metavar="FILE")
|
|
118
|
+
p.add_argument("--open", action="store_true", help="open the dashboard in a browser once it is ready")
|
|
119
|
+
p.set_defaults(fn=cmd_serve)
|
|
120
|
+
|
|
121
|
+
p = sub.add_parser("doctor", help="check every model role and report latency")
|
|
122
|
+
_add_settings_flags(p)
|
|
123
|
+
p.set_defaults(fn=cmd_doctor)
|
|
124
|
+
|
|
125
|
+
p = sub.add_parser("selftest", help="end-to-end test with synthesized speech (starts a server if none is up)")
|
|
126
|
+
_add_settings_flags(p)
|
|
127
|
+
p.add_argument("--url", help="WebSocket of a running server (default: ws://127.0.0.1:<port>/ws, started "
|
|
128
|
+
"in-process if nothing is listening)")
|
|
129
|
+
p.set_defaults(fn=cmd_selftest)
|
|
130
|
+
|
|
131
|
+
p = sub.add_parser("warmup", help="download and load the in-process speech models, then exit")
|
|
132
|
+
_add_settings_flags(p)
|
|
133
|
+
p.set_defaults(fn=cmd_warmup)
|
|
134
|
+
|
|
135
|
+
args = parser.parse_args(argv)
|
|
136
|
+
args.fn(args)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Settings: four model roles, each an OpenAI-compatible endpoint, plus the knobs around them.
|
|
2
|
+
|
|
3
|
+
ASR speech -> text POST {base}/audio/transcriptions
|
|
4
|
+
TTS text -> speech POST {base}/audio/speech
|
|
5
|
+
FOREGROUND holds the conversation POST {base}/chat/completions (also judges end-of-turn)
|
|
6
|
+
BACKGROUND works on handed-off tasks POST {base}/chat/completions
|
|
7
|
+
|
|
8
|
+
Every role reads <ROLE>_BASE_URL, <ROLE>_API_KEY and <ROLE>_MODEL. ASR and TTS run in-process when their
|
|
9
|
+
base URL is blank or "local"; BACKGROUND falls back to FOREGROUND field by field; FOREGROUND falls back to
|
|
10
|
+
OPENAI_BASE_URL / OPENAI_API_KEY. VAD is not a role: it runs on every 32 ms frame, in-process.
|
|
11
|
+
|
|
12
|
+
Precedence, highest first: command-line flags, environment, .env in the working directory, the TOML file
|
|
13
|
+
(--config, or ./interaction-harness.toml when it exists).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import tomllib
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
from dotenv import dotenv_values
|
|
25
|
+
|
|
26
|
+
from . import prompts
|
|
27
|
+
|
|
28
|
+
THINKING_MODES = ("chat_template_kwargs", "reasoning_effort", "off")
|
|
29
|
+
LOCAL_BACKENDS = ("auto", "mlx", "onnx")
|
|
30
|
+
DEFAULT_TOML = "interaction-harness.toml"
|
|
31
|
+
NO_KEY = "not-needed"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Endpoint:
|
|
36
|
+
"""One model role. `base_url` None means in-process (ASR/TTS only)."""
|
|
37
|
+
|
|
38
|
+
role: str
|
|
39
|
+
base_url: str | None
|
|
40
|
+
api_key: str
|
|
41
|
+
model: str
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def local(self) -> bool:
|
|
45
|
+
return self.base_url is None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def host(self) -> str:
|
|
49
|
+
"""Where the role runs, without path or credentials, for status lines and the dashboard."""
|
|
50
|
+
if self.base_url is None:
|
|
51
|
+
return "in-process"
|
|
52
|
+
p = urlparse(self.base_url)
|
|
53
|
+
return p.hostname + (f":{p.port}" if p.port else "") if p.hostname else self.base_url
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Settings:
|
|
58
|
+
foreground: Endpoint
|
|
59
|
+
background: Endpoint
|
|
60
|
+
asr: Endpoint
|
|
61
|
+
tts: Endpoint
|
|
62
|
+
foreground_thinking: str = "chat_template_kwargs"
|
|
63
|
+
background_thinking: str = "chat_template_kwargs"
|
|
64
|
+
background_temperature: float = 0.7
|
|
65
|
+
background_rounds: int = 10 # model calls per background task
|
|
66
|
+
asr_language: str | None = None
|
|
67
|
+
asr_partial_ms: int = 640 # partial transcript cadence; 0 = only when the judge needs one
|
|
68
|
+
tts_voice: str | None = None # None: the backend's first voice
|
|
69
|
+
tts_voices: list[str] = field(default_factory=list)
|
|
70
|
+
tts_format: str = "wav" # endpoint TTS: wav or pcm
|
|
71
|
+
tts_sample_rate: int = 24_000 # endpoint TTS with pcm: the server's rate
|
|
72
|
+
tts_speed: float = 1.05
|
|
73
|
+
backend: str = "auto" # in-process speech: auto | mlx | onnx
|
|
74
|
+
device: str = "cpu" # onnx backend: cpu | cuda
|
|
75
|
+
parakeet_onnx_variant: str = "int8" # fp32 | int8 (same transcripts in our tests; a quarter the download)
|
|
76
|
+
kokoro_onnx_variant: str = "int8" # fp32 | fp16 | int8 (about twice as fast on CPU)
|
|
77
|
+
foreground_prompt: str = prompts.FOREGROUND_SYSTEM
|
|
78
|
+
background_prompt: str = prompts.BACKGROUND_SYSTEM
|
|
79
|
+
end_of_turn_prompt: str = prompts.END_OF_TURN_SYSTEM
|
|
80
|
+
tools_files: list[str] = field(default_factory=list)
|
|
81
|
+
save_utterances: str | None = None # directory: keep a WAV of every user turn the ASR heard
|
|
82
|
+
host: str = "127.0.0.1"
|
|
83
|
+
port: int = 8765
|
|
84
|
+
ssl_certfile: str | None = None
|
|
85
|
+
ssl_keyfile: str | None = None
|
|
86
|
+
|
|
87
|
+
def roles(self) -> dict[str, Endpoint]:
|
|
88
|
+
return {"asr": self.asr, "tts": self.tts, "foreground": self.foreground, "background": self.background}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _same_host(a: str, b: str) -> bool:
|
|
92
|
+
pa, pb = urlparse(a), urlparse(b)
|
|
93
|
+
return bool(pa.hostname) and (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _flatten_toml(data: dict[str, Any]) -> dict[str, str]:
|
|
97
|
+
"""[foreground] base_url = ... -> FOREGROUND_BASE_URL; [prompts] foreground = ... -> FOREGROUND_PROMPT;
|
|
98
|
+
[server] port = ... (or a top-level port = ...) -> PORT."""
|
|
99
|
+
out: dict[str, str] = {}
|
|
100
|
+
for key, value in data.items():
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
for k, v in value.items():
|
|
103
|
+
name = f"{k}_PROMPT" if key == "prompts" else k if key == "server" else f"{key}_{k}"
|
|
104
|
+
out[name.upper()] = ",".join(map(str, v)) if isinstance(v, list) else str(v)
|
|
105
|
+
else:
|
|
106
|
+
out[key.upper()] = ",".join(map(str, value)) if isinstance(value, list) else str(value)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sources(flags: dict[str, Any] | None, config_path: str | None, env: dict[str, str] | None,
|
|
111
|
+
dotenv_path: str | None) -> dict[str, str]:
|
|
112
|
+
merged: dict[str, str] = {}
|
|
113
|
+
toml_path = Path(config_path) if config_path else Path(DEFAULT_TOML)
|
|
114
|
+
if config_path and not toml_path.exists():
|
|
115
|
+
raise FileNotFoundError(f"config file not found: {toml_path}")
|
|
116
|
+
if toml_path.exists():
|
|
117
|
+
merged.update(_flatten_toml(tomllib.loads(toml_path.read_text())))
|
|
118
|
+
dotenv = Path(dotenv_path) if dotenv_path else Path(".env")
|
|
119
|
+
if dotenv.exists():
|
|
120
|
+
merged.update({k: v for k, v in dotenv_values(dotenv).items() if v is not None})
|
|
121
|
+
merged.update(os.environ if env is None else env)
|
|
122
|
+
for k, v in (flags or {}).items():
|
|
123
|
+
if v is not None:
|
|
124
|
+
merged[k.upper()] = ",".join(map(str, v)) if isinstance(v, list) else str(v)
|
|
125
|
+
return merged
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_settings(flags: dict[str, Any] | None = None, *, config_path: str | None = None,
|
|
129
|
+
env: dict[str, str] | None = None, dotenv_path: str | None = None,
|
|
130
|
+
require_foreground: bool = True) -> Settings:
|
|
131
|
+
"""Build Settings from flags, env, .env and TOML. `flags` uses env-style keys (FOREGROUND_MODEL=...)."""
|
|
132
|
+
src = _sources(flags, config_path, env, dotenv_path)
|
|
133
|
+
|
|
134
|
+
def get(*keys: str, default: str | None = None) -> str | None:
|
|
135
|
+
for k in keys:
|
|
136
|
+
v = src.get(k)
|
|
137
|
+
if v is not None and str(v).strip() != "":
|
|
138
|
+
return str(v).strip()
|
|
139
|
+
return default
|
|
140
|
+
|
|
141
|
+
def speech_url(role: str) -> str | None:
|
|
142
|
+
url = get(f"{role}_BASE_URL")
|
|
143
|
+
return None if url is None or url.lower() == "local" else url.rstrip("/")
|
|
144
|
+
|
|
145
|
+
fg_url = (get("FOREGROUND_BASE_URL", "OPENAI_BASE_URL") or "").rstrip("/")
|
|
146
|
+
fg_key = get("FOREGROUND_API_KEY", "OPENAI_API_KEY", default=NO_KEY)
|
|
147
|
+
|
|
148
|
+
def key_for(role: str, url: str | None) -> str:
|
|
149
|
+
# a key only goes to the host it was set for: reuse the foreground key on the same host, never elsewhere
|
|
150
|
+
own = get(f"{role}_API_KEY")
|
|
151
|
+
if own:
|
|
152
|
+
return own
|
|
153
|
+
return fg_key if url and _same_host(url, fg_url) else NO_KEY
|
|
154
|
+
|
|
155
|
+
foreground = Endpoint("foreground", fg_url, fg_key, get("FOREGROUND_MODEL", default=""))
|
|
156
|
+
bg_url = (get("BACKGROUND_BASE_URL") or fg_url).rstrip("/")
|
|
157
|
+
background = Endpoint("background", bg_url, key_for("BACKGROUND", bg_url),
|
|
158
|
+
get("BACKGROUND_MODEL", default="") or (foreground.model if bg_url == fg_url else ""))
|
|
159
|
+
asr_url, tts_url = speech_url("ASR"), speech_url("TTS")
|
|
160
|
+
asr = Endpoint("asr", asr_url, key_for("ASR", asr_url), get("ASR_MODEL", default=""))
|
|
161
|
+
tts = Endpoint("tts", tts_url, key_for("TTS", tts_url), get("TTS_MODEL", default=""))
|
|
162
|
+
|
|
163
|
+
s = Settings(foreground=foreground, background=background, asr=asr, tts=tts)
|
|
164
|
+
s.foreground_thinking = get("FOREGROUND_THINKING", default=s.foreground_thinking)
|
|
165
|
+
# same server as the foreground: same way of switching thinking, unless told otherwise
|
|
166
|
+
s.background_thinking = get("BACKGROUND_THINKING", default=s.foreground_thinking if bg_url == fg_url
|
|
167
|
+
else s.background_thinking)
|
|
168
|
+
s.background_temperature = float(get("BACKGROUND_TEMPERATURE", default=str(s.background_temperature)))
|
|
169
|
+
s.background_rounds = int(get("BACKGROUND_ROUNDS", default=str(s.background_rounds)))
|
|
170
|
+
s.asr_language = get("ASR_LANGUAGE")
|
|
171
|
+
s.asr_partial_ms = int(get("ASR_PARTIAL_MS", default=str(s.asr_partial_ms)))
|
|
172
|
+
s.tts_voice = get("TTS_VOICE")
|
|
173
|
+
s.tts_voices = [v.strip() for v in (get("TTS_VOICES") or "").split(",") if v.strip()]
|
|
174
|
+
s.tts_format = get("TTS_FORMAT", default=s.tts_format).lower()
|
|
175
|
+
s.tts_sample_rate = int(get("TTS_SAMPLE_RATE", default=str(s.tts_sample_rate)))
|
|
176
|
+
s.tts_speed = float(get("TTS_SPEED", default=str(s.tts_speed)))
|
|
177
|
+
s.backend = get("BACKEND", default=s.backend).lower()
|
|
178
|
+
s.device = get("DEVICE", default=s.device).lower()
|
|
179
|
+
s.parakeet_onnx_variant = get("PARAKEET_ONNX_VARIANT", default=s.parakeet_onnx_variant).lower()
|
|
180
|
+
s.kokoro_onnx_variant = get("KOKORO_ONNX_VARIANT", default=s.kokoro_onnx_variant).lower()
|
|
181
|
+
s.foreground_prompt = get("FOREGROUND_PROMPT", default=s.foreground_prompt)
|
|
182
|
+
s.background_prompt = get("BACKGROUND_PROMPT", default=s.background_prompt)
|
|
183
|
+
s.end_of_turn_prompt = get("END_OF_TURN_PROMPT", default=s.end_of_turn_prompt)
|
|
184
|
+
s.tools_files = [p.strip() for p in (get("TOOLS") or "").split(",") if p.strip()]
|
|
185
|
+
s.save_utterances = get("SAVE_UTTERANCES")
|
|
186
|
+
s.host = get("HOST", default=s.host)
|
|
187
|
+
s.port = int(get("PORT", default=str(s.port)))
|
|
188
|
+
s.ssl_certfile = get("SSL_CERTFILE")
|
|
189
|
+
s.ssl_keyfile = get("SSL_KEYFILE")
|
|
190
|
+
validate(s, require_foreground)
|
|
191
|
+
return s
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate(s: Settings, require_foreground: bool = True) -> None:
|
|
195
|
+
for name in ("foreground_thinking", "background_thinking"):
|
|
196
|
+
if getattr(s, name) not in THINKING_MODES:
|
|
197
|
+
raise ValueError(f"{name.upper()} must be one of {', '.join(THINKING_MODES)}; got {getattr(s, name)!r}")
|
|
198
|
+
if s.backend not in LOCAL_BACKENDS:
|
|
199
|
+
raise ValueError(f"BACKEND must be one of {', '.join(LOCAL_BACKENDS)}; got {s.backend!r}")
|
|
200
|
+
if s.tts_format not in ("wav", "pcm"):
|
|
201
|
+
raise ValueError(f"TTS_FORMAT must be wav or pcm; got {s.tts_format!r}")
|
|
202
|
+
if s.device not in ("cpu", "cuda"):
|
|
203
|
+
raise ValueError(f"DEVICE must be cpu or cuda; got {s.device!r}")
|
|
204
|
+
if s.parakeet_onnx_variant not in ("fp32", "int8"):
|
|
205
|
+
raise ValueError(f"PARAKEET_ONNX_VARIANT must be fp32 or int8; got {s.parakeet_onnx_variant!r}")
|
|
206
|
+
if s.kokoro_onnx_variant not in ("fp32", "fp16", "int8"):
|
|
207
|
+
raise ValueError(f"KOKORO_ONNX_VARIANT must be fp32, fp16 or int8; got {s.kokoro_onnx_variant!r}")
|
|
208
|
+
if require_foreground and not s.foreground.base_url:
|
|
209
|
+
raise ValueError("No foreground model server. Set FOREGROUND_BASE_URL (or OPENAI_BASE_URL) to any "
|
|
210
|
+
"OpenAI-compatible endpoint, e.g. http://127.0.0.1:8080/v1")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Check every model role on its own, so a bad setting shows up before you start talking.
|
|
2
|
+
|
|
3
|
+
foreground streams, calls a tool when asked, and answers the end-of-turn prompt in one word
|
|
4
|
+
background streams with thinking on and calls a tool
|
|
5
|
+
TTS -> ASR speech made by the TTS comes back from the ASR as the same words
|
|
6
|
+
VAD hears that speech and not silence
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .config import Settings
|
|
16
|
+
from .llm import LLM, resolve_models
|
|
17
|
+
from .speech import MIC_SR, VAD_FRAME, build_speech
|
|
18
|
+
from .speech.base import resample
|
|
19
|
+
from .speech.silero import SileroVAD
|
|
20
|
+
from .tools import schemas
|
|
21
|
+
|
|
22
|
+
PHRASE = "The quick brown fox jumps over the lazy dog, and the interaction model decides who speaks next."
|
|
23
|
+
|
|
24
|
+
OK, WARN, FAIL = "ok", "warn", "FAIL"
|
|
25
|
+
_MARK = {OK: "✓", WARN: "!", FAIL: "✗"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Report:
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.rows: list[tuple[str, str, str, str]] = []
|
|
31
|
+
|
|
32
|
+
def add(self, check: str, status: str, detail: str, ms: float | None = None):
|
|
33
|
+
self.rows.append((check, status, detail, "" if ms is None else f"{ms:,.0f} ms"))
|
|
34
|
+
print(f" {_MARK[status]} {check:<26} {self.rows[-1][3]:>10} {detail}", flush=True)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def failed(self) -> int:
|
|
38
|
+
return sum(1 for r in self.rows if r[1] == FAIL)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _words(text: str) -> list[str]:
|
|
42
|
+
return re.findall(r"[a-z]+", text.lower())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def word_overlap(said: str, heard: str) -> float:
|
|
46
|
+
a, b = _words(said), set(_words(heard))
|
|
47
|
+
return sum(w in b for w in a) / max(1, len(a))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _chat_check(llm: LLM, report: Report, role: str, thinking: bool):
|
|
51
|
+
# the same system prompt and tools a real session sends, so the check measures what you will get
|
|
52
|
+
background = role == "background"
|
|
53
|
+
prompt = llm.s.background_prompt if background else llm.s.foreground_prompt
|
|
54
|
+
msgs = [{"role": "system", "content": prompt}, {"role": "user", "content": "What time is it right now?"}]
|
|
55
|
+
first = done = None
|
|
56
|
+
calls, reasoning, text = [], 0, []
|
|
57
|
+
t0 = time.perf_counter()
|
|
58
|
+
try:
|
|
59
|
+
async for ev in llm.stream_chat(msgs, tools=schemas(role), thinking=thinking, max_tokens=2000,
|
|
60
|
+
background=background):
|
|
61
|
+
if ev["type"] == "first_token":
|
|
62
|
+
first = ev["ms"]
|
|
63
|
+
elif ev["type"] == "reasoning":
|
|
64
|
+
reasoning += 1
|
|
65
|
+
elif ev["type"] == "content":
|
|
66
|
+
text.append(ev["delta"])
|
|
67
|
+
elif ev["type"] == "tool_calls":
|
|
68
|
+
calls = ev["calls"]
|
|
69
|
+
elif ev["type"] == "done":
|
|
70
|
+
done = ev
|
|
71
|
+
except Exception as e: # noqa: BLE001
|
|
72
|
+
report.add(f"{role}: chat", FAIL, f"{type(e).__name__}: {str(e)[:160]}", (time.perf_counter() - t0) * 1000)
|
|
73
|
+
return
|
|
74
|
+
report.add(f"{role}: first token", OK if first is not None else WARN,
|
|
75
|
+
"streamed" if first is not None else "no tokens streamed", first)
|
|
76
|
+
if any(c["name"] == "get_time" for c in calls):
|
|
77
|
+
report.add(f"{role}: tool call", OK, "called get_time", done["ms"] if done else None)
|
|
78
|
+
else:
|
|
79
|
+
said = "".join(text).strip()[:80]
|
|
80
|
+
report.add(f"{role}: tool call", WARN, f"did not call get_time for the time; it said {said!r}. If tools never "
|
|
81
|
+
"fire, check the server passes them to the model", done["ms"] if done else None)
|
|
82
|
+
if background:
|
|
83
|
+
report.add(f"{role}: reasoning", OK if reasoning else WARN,
|
|
84
|
+
f"{reasoning} reasoning deltas" if reasoning else
|
|
85
|
+
"no reasoning streamed; check BACKGROUND_THINKING for this server")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _judge_check(llm: LLM, report: Report):
|
|
89
|
+
for text, want in (("what time is it right now", "COMPLETE"), ("so I was wondering whether and", "INCOMPLETE")):
|
|
90
|
+
try:
|
|
91
|
+
complete, ms, raw = await llm.end_of_turn(text)
|
|
92
|
+
except Exception as e: # noqa: BLE001
|
|
93
|
+
report.add("foreground: end-of-turn", FAIL, f"{type(e).__name__}: {str(e)[:160]}")
|
|
94
|
+
return
|
|
95
|
+
got = "COMPLETE" if complete else "INCOMPLETE"
|
|
96
|
+
report.add(f"judge: {want.lower()}", OK if got == want else WARN,
|
|
97
|
+
f"{text!r} -> {raw or '(empty)'}" + ("" if got == want else f" (expected {want})"), ms)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def speech_round_trip(settings: Settings, report: Report) -> None:
|
|
101
|
+
try:
|
|
102
|
+
speech = await build_speech(settings, log=lambda m: None)
|
|
103
|
+
except Exception as e: # noqa: BLE001
|
|
104
|
+
report.add("speech: load", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
|
|
105
|
+
return
|
|
106
|
+
asr, tts = speech.asr, speech.tts
|
|
107
|
+
report.add("asr: model", OK, f"{asr.model_id} ({asr.where})")
|
|
108
|
+
report.add("tts: model", OK, f"{tts.model_id} ({tts.where}, voice {settings.tts_voice})")
|
|
109
|
+
try:
|
|
110
|
+
pcm, sr, ms = await tts.synth(PHRASE, settings.tts_voice, 1.0)
|
|
111
|
+
except Exception as e: # noqa: BLE001
|
|
112
|
+
report.add("tts: synthesize", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
|
|
113
|
+
return
|
|
114
|
+
dur = len(pcm) / sr if sr else 0
|
|
115
|
+
report.add("tts: synthesize", OK if dur > 1 else FAIL, f"{dur:.2f}s of audio at {sr} Hz (RTF {ms / 1000 / max(dur, 1e-6):.2f})", ms)
|
|
116
|
+
audio16 = resample(pcm, sr, MIC_SR).astype(np.float32) / 32768.0
|
|
117
|
+
try:
|
|
118
|
+
text, ms = await asr.transcribe(audio16)
|
|
119
|
+
except Exception as e: # noqa: BLE001
|
|
120
|
+
report.add("asr: transcribe", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
|
|
121
|
+
return
|
|
122
|
+
overlap = word_overlap(PHRASE, text)
|
|
123
|
+
report.add("tts -> asr round trip", OK if overlap >= 0.8 else WARN if overlap >= 0.5 else FAIL,
|
|
124
|
+
f"{overlap:.0%} of words back: {text[:80]!r}", ms)
|
|
125
|
+
stream = await asr.stream()
|
|
126
|
+
step = MIC_SR // 2
|
|
127
|
+
t0 = time.perf_counter()
|
|
128
|
+
partial = ""
|
|
129
|
+
for i in range(0, len(audio16), step):
|
|
130
|
+
partial, _ = await stream.feed(audio16[i:i + step])
|
|
131
|
+
await stream.close()
|
|
132
|
+
report.add("asr: partials", OK if word_overlap(PHRASE, partial) >= 0.5 else WARN,
|
|
133
|
+
f"{len(range(0, len(audio16), step))} partials, last {partial[-50:]!r}", (time.perf_counter() - t0) * 1000)
|
|
134
|
+
vad = SileroVAD()
|
|
135
|
+
frames = (audio16 * 32767).astype(np.int16)
|
|
136
|
+
probs = [vad.prob(frames[i:i + VAD_FRAME]) for i in range(0, len(frames) - VAD_FRAME, VAD_FRAME)]
|
|
137
|
+
vad.reset()
|
|
138
|
+
quiet = max(vad.prob(np.zeros(VAD_FRAME, dtype=np.int16)) for _ in range(10))
|
|
139
|
+
speechy = float(np.mean(np.array(probs) > 0.5)) if probs else 0.0
|
|
140
|
+
report.add("vad", OK if speechy > 0.4 and quiet < 0.2 else WARN,
|
|
141
|
+
f"speech frames {speechy:.0%}, max p on silence {quiet:.2f}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def run(settings: Settings) -> int:
|
|
145
|
+
report = Report()
|
|
146
|
+
print("model roles", flush=True)
|
|
147
|
+
for name, ep in settings.roles().items():
|
|
148
|
+
print(f" {name:<11} {ep.model or '(server default)':<44} {ep.host}", flush=True)
|
|
149
|
+
print("\nforeground + background", flush=True)
|
|
150
|
+
try:
|
|
151
|
+
for note in await resolve_models(settings):
|
|
152
|
+
report.add("model", OK, note)
|
|
153
|
+
except Exception as e: # noqa: BLE001
|
|
154
|
+
report.add("model list", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
|
|
155
|
+
if settings.foreground.model:
|
|
156
|
+
llm = LLM(settings)
|
|
157
|
+
await _chat_check(llm, report, "foreground", thinking=False)
|
|
158
|
+
await _judge_check(llm, report)
|
|
159
|
+
await _chat_check(llm, report, "background", thinking=True) # thinking on: its own check even on one server
|
|
160
|
+
print("\nspeech", flush=True)
|
|
161
|
+
await speech_round_trip(settings, report)
|
|
162
|
+
print(f"\n{'all checks passed' if not report.failed else f'{report.failed} check(s) failed'}"
|
|
163
|
+
f"{'' if not any(r[1] == WARN for r in report.rows) else ' (with warnings)'}", flush=True)
|
|
164
|
+
return 1 if report.failed else 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def warmup(settings: Settings) -> int:
|
|
168
|
+
"""Download and load the in-process models, then check they work together."""
|
|
169
|
+
report = Report()
|
|
170
|
+
await speech_round_trip(settings, report)
|
|
171
|
+
return 1 if report.failed else 0
|