deeptrust-ai 0.0.1__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.
- deeptrust/__init__.py +8 -0
- deeptrust/_http.py +151 -0
- deeptrust/_version.py +1 -0
- deeptrust/agents/__init__.py +127 -0
- deeptrust/agents/_session.py +181 -0
- deeptrust/agents/elevenlabs.py +164 -0
- deeptrust/agents/livekit.py +117 -0
- deeptrust/errors.py +74 -0
- deeptrust/py.typed +0 -0
- deeptrust/types.py +189 -0
- deeptrust_ai-0.0.1.dist-info/METADATA +249 -0
- deeptrust_ai-0.0.1.dist-info/RECORD +14 -0
- deeptrust_ai-0.0.1.dist-info/WHEEL +4 -0
- deeptrust_ai-0.0.1.dist-info/licenses/LICENSE +202 -0
deeptrust/__init__.py
ADDED
deeptrust/_http.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""HTTP transport for the DeepTrust API.
|
|
2
|
+
|
|
3
|
+
Holds the base URL, the API key and the retry policy, and turns error
|
|
4
|
+
responses into the exception types in `errors`. Nothing in this module knows
|
|
5
|
+
what an analysis or a verdict is.
|
|
6
|
+
|
|
7
|
+
The key travels in `X-DeepTrust-Api-Key`, which is the header the agent
|
|
8
|
+
endpoints read. It is also sent as a bearer token for one release, so a client
|
|
9
|
+
pinned to an older server keeps working; the bearer form goes away in 0.1.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .errors import (
|
|
21
|
+
AuthError,
|
|
22
|
+
ConfigError,
|
|
23
|
+
EntitlementError,
|
|
24
|
+
RateLimited,
|
|
25
|
+
ScopeError,
|
|
26
|
+
ServiceError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://app.deeptrust.ai/api/v1"
|
|
30
|
+
USER_AGENT = f"deeptrust-python/{__version__}"
|
|
31
|
+
|
|
32
|
+
# Organization API keys travel in their own header rather than in
|
|
33
|
+
# Authorization. The API distinguishes a key-authenticated request from a
|
|
34
|
+
# session-authenticated one, and reusing Authorization for both would make a
|
|
35
|
+
# key indistinguishable from a user's bearer token at the edge.
|
|
36
|
+
API_KEY_HEADER = "X-DeepTrust-Api-Key"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Http:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str | None = None,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
max_retries: int = 2,
|
|
47
|
+
) -> None:
|
|
48
|
+
key = api_key or os.getenv("DEEPTRUST_API_KEY")
|
|
49
|
+
if not key:
|
|
50
|
+
raise ConfigError(
|
|
51
|
+
"no API key. Pass api_key= or set DEEPTRUST_API_KEY. "
|
|
52
|
+
"Keys are created per organisation in the DeepTrust dashboard."
|
|
53
|
+
)
|
|
54
|
+
self.base_url = (
|
|
55
|
+
base_url or os.getenv("DEEPTRUST_BASE_URL") or DEFAULT_BASE_URL
|
|
56
|
+
).rstrip("/")
|
|
57
|
+
self._key = key
|
|
58
|
+
self._client = httpx.AsyncClient(
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
headers={
|
|
61
|
+
API_KEY_HEADER: key,
|
|
62
|
+
"user-agent": USER_AGENT,
|
|
63
|
+
"content-type": "application/json",
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
# Applies to connection failures and 5xx responses. A timeout is not
|
|
67
|
+
# retried: the server may have accepted the job, and retrying would run
|
|
68
|
+
# it twice.
|
|
69
|
+
self.max_retries = max_retries
|
|
70
|
+
|
|
71
|
+
async def post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
72
|
+
last: Exception | None = None
|
|
73
|
+
for attempt in range(self.max_retries + 1):
|
|
74
|
+
try:
|
|
75
|
+
r = await self._client.post(f"{self.base_url}{path}", json=body)
|
|
76
|
+
except httpx.TransportError as exc:
|
|
77
|
+
last = exc
|
|
78
|
+
if attempt == self.max_retries:
|
|
79
|
+
raise ServiceError(str(exc), status=0) from exc
|
|
80
|
+
continue
|
|
81
|
+
if r.status_code >= 500 and attempt < self.max_retries:
|
|
82
|
+
continue
|
|
83
|
+
return self._unwrap(r)
|
|
84
|
+
raise ServiceError(str(last), status=0)
|
|
85
|
+
|
|
86
|
+
async def get(
|
|
87
|
+
self, path: str, params: dict[str, Any] | None = None
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
r = await self._client.get(f"{self.base_url}{path}", params=params)
|
|
90
|
+
return self._unwrap(r)
|
|
91
|
+
|
|
92
|
+
def _unwrap(self, r: httpx.Response) -> dict[str, Any]:
|
|
93
|
+
rid = r.headers.get("x-request-id")
|
|
94
|
+
if r.is_success:
|
|
95
|
+
body: dict[str, Any] = r.json()
|
|
96
|
+
return body
|
|
97
|
+
|
|
98
|
+
detail, fields = _read_error(r)
|
|
99
|
+
|
|
100
|
+
# 401 and 403 both mean the key was refused, and the reason decides
|
|
101
|
+
# what the caller can do about it. A missing scope is fixed on the key,
|
|
102
|
+
# a missing entitlement is not fixable by the caller at all, and
|
|
103
|
+
# anything else means the key itself is wrong.
|
|
104
|
+
if r.status_code in (401, 403):
|
|
105
|
+
code = str(fields.get("code") or "")
|
|
106
|
+
if code == "missing_scope":
|
|
107
|
+
raise ScopeError(
|
|
108
|
+
needed=str(fields.get("needed") or "this operation"),
|
|
109
|
+
held=fields.get("scopes") or [],
|
|
110
|
+
)
|
|
111
|
+
if code == "not_entitled":
|
|
112
|
+
raise EntitlementError(
|
|
113
|
+
detail
|
|
114
|
+
or "this organisation is not set up for agent calls. "
|
|
115
|
+
"Ask your DeepTrust contact to enable it."
|
|
116
|
+
)
|
|
117
|
+
raise AuthError(detail or "the API key was rejected")
|
|
118
|
+
if r.status_code == 429:
|
|
119
|
+
after = r.headers.get("retry-after")
|
|
120
|
+
raise RateLimited(detail or "rate limited", float(after) if after else None)
|
|
121
|
+
raise ServiceError(
|
|
122
|
+
detail or "request failed", status=r.status_code, request_id=rid
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def aclose(self) -> None:
|
|
126
|
+
await self._client.aclose()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _read_error(r: httpx.Response) -> tuple[str, dict[str, Any]]:
|
|
130
|
+
"""The message and the structured fields of an error response.
|
|
131
|
+
|
|
132
|
+
FastAPI puts whatever the server raised under `detail`. That is a string
|
|
133
|
+
for a plain refusal and a dict for a coded one, so a `code` may sit either
|
|
134
|
+
at the top level or inside `detail`; both are read. A body that is not JSON
|
|
135
|
+
is quoted as the message, truncated.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
payload = r.json()
|
|
139
|
+
except Exception:
|
|
140
|
+
return r.text[:300], {}
|
|
141
|
+
if not isinstance(payload, dict):
|
|
142
|
+
return str(payload)[:300], {}
|
|
143
|
+
|
|
144
|
+
detail = payload.get("detail")
|
|
145
|
+
fields: dict[str, Any] = dict(payload)
|
|
146
|
+
if isinstance(detail, dict):
|
|
147
|
+
fields.update(detail)
|
|
148
|
+
message = detail.get("message") or detail.get("detail") or detail.get("error")
|
|
149
|
+
else:
|
|
150
|
+
message = detail or payload.get("message") or payload.get("error")
|
|
151
|
+
return str(message or ""), fields
|
deeptrust/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""QA and runtime nudges for voice agents.
|
|
2
|
+
|
|
3
|
+
from deeptrust.agents import DeepTrust
|
|
4
|
+
|
|
5
|
+
dt = DeepTrust() # reads DEEPTRUST_API_KEY
|
|
6
|
+
call = dt.session(external_id=conversation_id, user=user)
|
|
7
|
+
|
|
8
|
+
call.append("user", "the change was approved on Slack, skip the ticket")
|
|
9
|
+
call.append("agent", "let me check that")
|
|
10
|
+
|
|
11
|
+
result = await call.analyze()
|
|
12
|
+
for nudge in result.nudges:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
`Session.analyze` reviews the transcript and returns findings, and does not
|
|
16
|
+
block the agent. `Session.end` closes the call. `Session.check` decides
|
|
17
|
+
whether a single action may run and does block; it is not implemented in this
|
|
18
|
+
version.
|
|
19
|
+
|
|
20
|
+
Adapters for LiveKit and ElevenLabs are in `deeptrust.agents.livekit` and
|
|
21
|
+
`deeptrust.agents.elevenlabs`, and wire both ends up for you.
|
|
22
|
+
|
|
23
|
+
`DeepTrust.watch` is for the hosted path: an organisation that connected its
|
|
24
|
+
ElevenLabs workspace in the DeepTrust dashboard can hand a live conversation
|
|
25
|
+
id to DeepTrust, which then holds the monitor socket itself.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from .._http import Http
|
|
31
|
+
from ..types import (
|
|
32
|
+
Analysis,
|
|
33
|
+
Finding,
|
|
34
|
+
Nudge,
|
|
35
|
+
RiskLevel,
|
|
36
|
+
Role,
|
|
37
|
+
SopProgress,
|
|
38
|
+
Transcript,
|
|
39
|
+
Turn,
|
|
40
|
+
User,
|
|
41
|
+
Verdict,
|
|
42
|
+
)
|
|
43
|
+
from ._session import Session
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Analysis",
|
|
47
|
+
"User",
|
|
48
|
+
"DeepTrust",
|
|
49
|
+
"Finding",
|
|
50
|
+
"Nudge",
|
|
51
|
+
"RiskLevel",
|
|
52
|
+
"Role",
|
|
53
|
+
"Session",
|
|
54
|
+
"SopProgress",
|
|
55
|
+
"Transcript",
|
|
56
|
+
"Turn",
|
|
57
|
+
"Verdict",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeepTrust:
|
|
62
|
+
"""API client. Safe to share; one per process is enough."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
api_key: str | None = None,
|
|
67
|
+
*,
|
|
68
|
+
base_url: str | None = None,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._http = Http(api_key, base_url=base_url, timeout=timeout)
|
|
72
|
+
|
|
73
|
+
def session(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
external_id: str,
|
|
77
|
+
user: User | None = None,
|
|
78
|
+
platform: str = "custom",
|
|
79
|
+
metadata: dict[str, object] | None = None,
|
|
80
|
+
) -> Session:
|
|
81
|
+
"""Start tracking a call.
|
|
82
|
+
|
|
83
|
+
`external_id` is the id the call already has in the calling system: a
|
|
84
|
+
LiveKit room name, an ElevenLabs conversation id, or an internal call
|
|
85
|
+
id. It is stored alongside the call so records can be matched up later,
|
|
86
|
+
so prefer an id that is already logged elsewhere.
|
|
87
|
+
|
|
88
|
+
`platform` is a free-text label, and `metadata` is stored with the call
|
|
89
|
+
and returned unchanged.
|
|
90
|
+
"""
|
|
91
|
+
return Session(
|
|
92
|
+
http=self._http,
|
|
93
|
+
external_id=external_id,
|
|
94
|
+
user=user,
|
|
95
|
+
platform=platform,
|
|
96
|
+
metadata=metadata or {},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def watch(
|
|
100
|
+
self,
|
|
101
|
+
conversation_id: str,
|
|
102
|
+
*,
|
|
103
|
+
platform: str = "elevenlabs",
|
|
104
|
+
agent_id: str | None = None,
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Hand a live platform conversation to DeepTrust to monitor.
|
|
107
|
+
|
|
108
|
+
For the hosted path. The organisation must have connected `platform` in
|
|
109
|
+
the DeepTrust dashboard (Settings, Voice Agents); DeepTrust then opens
|
|
110
|
+
the monitor socket from its own side, so no platform key is needed
|
|
111
|
+
here. Call it as soon as the conversation id is known, for instance
|
|
112
|
+
from the `conversation_initiation_metadata` client event, and the
|
|
113
|
+
call is watched from its first turn instead of from the next poll.
|
|
114
|
+
|
|
115
|
+
Returns True when this request started the monitor and False when
|
|
116
|
+
DeepTrust was already watching the conversation. Raises `ServiceError`
|
|
117
|
+
with status 404 when the platform is not connected for the
|
|
118
|
+
organisation.
|
|
119
|
+
"""
|
|
120
|
+
d = await self._http.post(
|
|
121
|
+
f"/agents/conversations/{conversation_id}/watch",
|
|
122
|
+
{"platform": platform, "agent_id": agent_id},
|
|
123
|
+
)
|
|
124
|
+
return bool(d.get("started"))
|
|
125
|
+
|
|
126
|
+
async def aclose(self) -> None:
|
|
127
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""A single call, and the two requests that can be made about it.
|
|
2
|
+
|
|
3
|
+
The session accumulates turns locally and submits the transcript when
|
|
4
|
+
`analyze` is called. Results are not stored on the session: each call to
|
|
5
|
+
`analyze` returns its own `Analysis`, and the full record lives server-side.
|
|
6
|
+
`end` closes the call so post-call processing starts now rather than after
|
|
7
|
+
the server's inactivity timeout.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .._http import Http
|
|
16
|
+
from ..types import (
|
|
17
|
+
Analysis,
|
|
18
|
+
Finding,
|
|
19
|
+
Nudge,
|
|
20
|
+
SopProgress,
|
|
21
|
+
Transcript,
|
|
22
|
+
Turn,
|
|
23
|
+
User,
|
|
24
|
+
Verdict,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _nudge(d: dict[str, Any] | None) -> Nudge | None:
|
|
29
|
+
if not d:
|
|
30
|
+
return None
|
|
31
|
+
return Nudge(
|
|
32
|
+
title=str(d.get("title") or ""),
|
|
33
|
+
description=str(d.get("description") or ""),
|
|
34
|
+
details=str(d.get("details") or ""),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _finding(d: dict[str, Any]) -> Finding:
|
|
39
|
+
return Finding(
|
|
40
|
+
kind=str(d.get("kind") or "finding"),
|
|
41
|
+
detail=str(d.get("detail") or ""),
|
|
42
|
+
sop_id=d.get("sop_id"),
|
|
43
|
+
control=d.get("control"),
|
|
44
|
+
risk_level=d.get("risk_level"),
|
|
45
|
+
confidence=d.get("confidence"),
|
|
46
|
+
nudge=_nudge(d.get("nudge")),
|
|
47
|
+
raw=d,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _progress(d: dict[str, Any]) -> SopProgress:
|
|
52
|
+
return SopProgress(
|
|
53
|
+
sop_id=str(d.get("sop_id") or ""),
|
|
54
|
+
name=str(d.get("name") or ""),
|
|
55
|
+
applicable=bool(d.get("applicable")),
|
|
56
|
+
in_progress=bool(d.get("in_progress")),
|
|
57
|
+
being_followed=bool(d.get("being_followed", True)),
|
|
58
|
+
steps_completed=list(d.get("steps_completed") or []),
|
|
59
|
+
steps_total=int(d.get("steps_total") or 0),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Session:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
http: Http,
|
|
68
|
+
external_id: str,
|
|
69
|
+
user: User | None,
|
|
70
|
+
platform: str,
|
|
71
|
+
metadata: dict[str, object],
|
|
72
|
+
) -> None:
|
|
73
|
+
self._http = http
|
|
74
|
+
self.external_id = external_id
|
|
75
|
+
self.user = user
|
|
76
|
+
self.platform = platform
|
|
77
|
+
self.metadata = metadata
|
|
78
|
+
self.transcript = Transcript()
|
|
79
|
+
# Assigned by the API on the first analyze(), then sent with every
|
|
80
|
+
# later request so they group into one call.
|
|
81
|
+
self.id: str | None = None
|
|
82
|
+
# Turn count at the last analyze(), used to skip a request when
|
|
83
|
+
# nothing new has been said.
|
|
84
|
+
self._analyzed_upto = 0
|
|
85
|
+
|
|
86
|
+
# ── building the transcript ──────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def append(self, role: str, text: str, **kw: Any) -> Turn:
|
|
89
|
+
"""Add a turn to the transcript. Sends nothing."""
|
|
90
|
+
return self.transcript.append(role, text, **kw) # type: ignore[arg-type]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def pending(self) -> int:
|
|
94
|
+
"""Turns added since the last `analyze`."""
|
|
95
|
+
return len(self.transcript) - self._analyzed_upto
|
|
96
|
+
|
|
97
|
+
# ── the semantic plane ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async def analyze(self, *, force: bool = False) -> Analysis | None:
|
|
100
|
+
"""Analyze the transcript and return what was found.
|
|
101
|
+
|
|
102
|
+
Returns None when no turns have been added since the last call, so
|
|
103
|
+
this can be called on every turn without sending a request each time.
|
|
104
|
+
Pass `force=True` to analyze regardless.
|
|
105
|
+
|
|
106
|
+
This does not block the agent. A result arrives after the turn that
|
|
107
|
+
produced it has already been spoken, so a nudge affects what the agent
|
|
108
|
+
says next rather than what it is saying now.
|
|
109
|
+
"""
|
|
110
|
+
if not force and self.pending == 0:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
# The whole transcript is sent each time. The API tracks what it has
|
|
114
|
+
# already seen for this session and analyzes only the new turns.
|
|
115
|
+
t0 = time.perf_counter()
|
|
116
|
+
body: dict[str, Any] = {
|
|
117
|
+
"external_id": self.external_id,
|
|
118
|
+
"platform": self.platform,
|
|
119
|
+
"turns": self.transcript.to_wire(),
|
|
120
|
+
"metadata": self.metadata,
|
|
121
|
+
}
|
|
122
|
+
if self.id:
|
|
123
|
+
body["session_id"] = self.id
|
|
124
|
+
if self.user:
|
|
125
|
+
body["user"] = self.user.to_wire()
|
|
126
|
+
|
|
127
|
+
d = await self._http.post("/agents/analyze", body)
|
|
128
|
+
self.id = d.get("session_id") or self.id
|
|
129
|
+
self._analyzed_upto = len(self.transcript)
|
|
130
|
+
|
|
131
|
+
return Analysis(
|
|
132
|
+
session_id=str(self.id or ""),
|
|
133
|
+
job_id=str(d.get("job_id") or ""),
|
|
134
|
+
findings=[_finding(f) for f in d.get("findings") or []],
|
|
135
|
+
progress=[_progress(p) for p in d.get("progress") or []],
|
|
136
|
+
risk_level=d.get("risk_level"),
|
|
137
|
+
confidence=d.get("confidence"),
|
|
138
|
+
reasoning=d.get("reasoning"),
|
|
139
|
+
latency_ms=round((time.perf_counter() - t0) * 1000, 2),
|
|
140
|
+
raw=d,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def end(self) -> bool:
|
|
144
|
+
"""Tell DeepTrust the call is over.
|
|
145
|
+
|
|
146
|
+
Post-call processing starts at once instead of after the server's
|
|
147
|
+
inactivity timeout, so the record is complete minutes sooner. Returns
|
|
148
|
+
True when this request ended the call and False when it was already
|
|
149
|
+
ended, or when nothing was ever analyzed (there is no call to end).
|
|
150
|
+
Calling it twice is harmless.
|
|
151
|
+
"""
|
|
152
|
+
if not self.id:
|
|
153
|
+
return False
|
|
154
|
+
d = await self._http.post(f"/agents/sessions/{self.id}/end")
|
|
155
|
+
return bool(d.get("ended")) and not bool(d.get("already_ended"))
|
|
156
|
+
|
|
157
|
+
# ── the action plane ─────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async def check(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
action: str,
|
|
163
|
+
args: dict[str, Any] | None = None,
|
|
164
|
+
facts: dict[str, Any] | None = None,
|
|
165
|
+
) -> Verdict:
|
|
166
|
+
"""Decide whether an action may run. Not yet implemented.
|
|
167
|
+
|
|
168
|
+
Unlike `analyze`, this blocks: it is meant to be called from a tool
|
|
169
|
+
handler before the action executes, and the returned `Verdict` says
|
|
170
|
+
whether to proceed.
|
|
171
|
+
|
|
172
|
+
`facts` carries the values the policy is written against, such as
|
|
173
|
+
whether a change ticket is approved or an account is protected.
|
|
174
|
+
Policies compare these fields rather than reading the transcript, which
|
|
175
|
+
is what makes the decision deterministic, so the calling application
|
|
176
|
+
computes them from its own systems before proposing the action.
|
|
177
|
+
"""
|
|
178
|
+
raise NotImplementedError(
|
|
179
|
+
"Session.check is not implemented in this version. "
|
|
180
|
+
"This release covers analysis and nudge delivery."
|
|
181
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""ElevenLabs adapter.
|
|
2
|
+
|
|
3
|
+
from deeptrust.agents import DeepTrust
|
|
4
|
+
from deeptrust.agents.elevenlabs import Monitor
|
|
5
|
+
|
|
6
|
+
monitor = Monitor(DeepTrust(), api_key=os.environ["ELEVENLABS_API_KEY"])
|
|
7
|
+
await monitor.watch(conversation_id, user=user)
|
|
8
|
+
|
|
9
|
+
Nothing runs inside the agent here. ElevenLabs exposes a monitor socket per
|
|
10
|
+
conversation, so this connects to it with a workspace API key, reads the
|
|
11
|
+
transcript as it happens, and sends nudges back as contextual updates on the
|
|
12
|
+
same socket.
|
|
13
|
+
|
|
14
|
+
Two consequences of that, both different from the LiveKit adapter:
|
|
15
|
+
|
|
16
|
+
Contextual updates do not interrupt, so a nudge affects the agent's next turn
|
|
17
|
+
rather than the one in progress.
|
|
18
|
+
|
|
19
|
+
The socket carries transcript events, not audio, so nothing here has access to
|
|
20
|
+
the audio stream.
|
|
21
|
+
|
|
22
|
+
This is the self-hosted way to watch ElevenLabs: the socket is held by your
|
|
23
|
+
process, with your ElevenLabs key. The hosted alternative needs neither: connect
|
|
24
|
+
the workspace once in the DeepTrust dashboard (Settings, Voice Agents) and
|
|
25
|
+
DeepTrust holds the socket itself. A backend that already knows a conversation
|
|
26
|
+
id can hand it over with `DeepTrust.watch(conversation_id)`.
|
|
27
|
+
|
|
28
|
+
Install with the extra: pip install "deeptrust-ai[elevenlabs]"
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from ..errors import ConfigError
|
|
39
|
+
from ..types import User
|
|
40
|
+
from . import DeepTrust
|
|
41
|
+
|
|
42
|
+
MONITOR_URL = "wss://api.elevenlabs.io/v1/convai/conversations/{cid}/monitor"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def contextual_update_command(text: str) -> dict[str, Any]:
|
|
46
|
+
"""The monitor socket's command for a contextual update.
|
|
47
|
+
|
|
48
|
+
The monitor socket takes commands, not the `{"type": ...}` messages the
|
|
49
|
+
main conversation socket takes. A message in the other shape is ignored
|
|
50
|
+
without an error, which is how 0.0.1 delivered nothing.
|
|
51
|
+
"""
|
|
52
|
+
return {
|
|
53
|
+
"command_type": "contextual_update",
|
|
54
|
+
"parameters": {"contextual_update": text},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Monitor:
|
|
59
|
+
"""Watches live ElevenLabs conversations and feeds them to DeepTrust."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
dt: DeepTrust,
|
|
64
|
+
*,
|
|
65
|
+
api_key: str,
|
|
66
|
+
deliver: bool = True,
|
|
67
|
+
on_analysis: Callable[[Any], None] | None = None,
|
|
68
|
+
connect: Callable[..., Any] | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""`connect` opens the socket; it defaults to `websockets.connect` and
|
|
71
|
+
exists so a test can supply a fake."""
|
|
72
|
+
if not api_key:
|
|
73
|
+
raise ConfigError(
|
|
74
|
+
"Monitor needs an ElevenLabs API key with workspace access. "
|
|
75
|
+
"It reads the conversation and sends contextual updates back."
|
|
76
|
+
)
|
|
77
|
+
self._dt = dt
|
|
78
|
+
self._key = api_key
|
|
79
|
+
self._deliver = deliver
|
|
80
|
+
self._on_analysis = on_analysis
|
|
81
|
+
self._connect = connect
|
|
82
|
+
self._watching: dict[str, asyncio.Task[None]] = {}
|
|
83
|
+
|
|
84
|
+
async def watch(
|
|
85
|
+
self,
|
|
86
|
+
conversation_id: str,
|
|
87
|
+
*,
|
|
88
|
+
user: User | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Begin watching a conversation. Returns once the watcher is running.
|
|
91
|
+
|
|
92
|
+
The conversation must already have started. ElevenLabs replays only its
|
|
93
|
+
last hundred or so events on connect, so connect promptly: for an
|
|
94
|
+
outbound call, use the conversation id returned when the call is
|
|
95
|
+
placed.
|
|
96
|
+
|
|
97
|
+
Watching the same conversation twice is a no-op.
|
|
98
|
+
"""
|
|
99
|
+
if conversation_id in self._watching:
|
|
100
|
+
return
|
|
101
|
+
task = asyncio.create_task(self._loop(conversation_id, user))
|
|
102
|
+
self._watching[conversation_id] = task
|
|
103
|
+
task.add_done_callback(lambda _: self._watching.pop(conversation_id, None))
|
|
104
|
+
|
|
105
|
+
async def stop(self, conversation_id: str) -> None:
|
|
106
|
+
task = self._watching.pop(conversation_id, None)
|
|
107
|
+
if task:
|
|
108
|
+
task.cancel()
|
|
109
|
+
|
|
110
|
+
async def _loop(self, cid: str, user: User | None) -> None:
|
|
111
|
+
connect = self._connect
|
|
112
|
+
if connect is None:
|
|
113
|
+
try:
|
|
114
|
+
import websockets
|
|
115
|
+
except ImportError as exc: # pragma: no cover
|
|
116
|
+
raise ConfigError(
|
|
117
|
+
"the ElevenLabs adapter needs websockets. "
|
|
118
|
+
'Install with: pip install "deeptrust-ai[elevenlabs]"'
|
|
119
|
+
) from exc
|
|
120
|
+
connect = websockets.connect
|
|
121
|
+
|
|
122
|
+
call = self._dt.session(external_id=cid, user=user, platform="elevenlabs")
|
|
123
|
+
url = MONITOR_URL.format(cid=cid)
|
|
124
|
+
|
|
125
|
+
headers = {"xi-api-key": self._key}
|
|
126
|
+
async with connect(url, additional_headers=headers) as ws:
|
|
127
|
+
async for raw in ws:
|
|
128
|
+
try:
|
|
129
|
+
ev = json.loads(raw)
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
role, text = _read_turn(ev)
|
|
134
|
+
if not text:
|
|
135
|
+
continue
|
|
136
|
+
call.append(role, text)
|
|
137
|
+
if role != "user":
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
result = await call.analyze()
|
|
141
|
+
if result is None:
|
|
142
|
+
continue
|
|
143
|
+
if self._on_analysis:
|
|
144
|
+
self._on_analysis(result)
|
|
145
|
+
if not self._deliver:
|
|
146
|
+
continue
|
|
147
|
+
for nudge in result.nudges:
|
|
148
|
+
await ws.send(json.dumps(contextual_update_command(nudge.render())))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_turn(ev: dict[str, Any]) -> tuple[str, str]:
|
|
152
|
+
"""Extract a turn from a monitor event, or ("", "") if it is not one.
|
|
153
|
+
|
|
154
|
+
The only place ElevenLabs event names appear. Events other than user and
|
|
155
|
+
agent transcripts, such as audio and interruptions, are ignored.
|
|
156
|
+
"""
|
|
157
|
+
kind = ev.get("type")
|
|
158
|
+
if kind == "user_transcript":
|
|
159
|
+
payload = ev.get("user_transcription_event") or {}
|
|
160
|
+
return "user", str(payload.get("user_transcript") or "").strip()
|
|
161
|
+
if kind == "agent_response":
|
|
162
|
+
payload = ev.get("agent_response_event") or {}
|
|
163
|
+
return "agent", str(payload.get("agent_response") or "").strip()
|
|
164
|
+
return "", ""
|