ermis-voice 0.1.0a1__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.
- ermis_voice-0.1.0a1/.github/workflows/ci.yml +22 -0
- ermis_voice-0.1.0a1/.github/workflows/publish.yml +36 -0
- ermis_voice-0.1.0a1/.gitignore +9 -0
- ermis_voice-0.1.0a1/CHANGELOG.md +5 -0
- ermis_voice-0.1.0a1/LICENSE +21 -0
- ermis_voice-0.1.0a1/PKG-INFO +225 -0
- ermis_voice-0.1.0a1/README.md +196 -0
- ermis_voice-0.1.0a1/docs/api.md +390 -0
- ermis_voice-0.1.0a1/examples/atendente_twilio.py +143 -0
- ermis_voice-0.1.0a1/pyproject.toml +59 -0
- ermis_voice-0.1.0a1/src/ermis/__init__.py +84 -0
- ermis_voice-0.1.0a1/src/ermis/call.py +262 -0
- ermis_voice-0.1.0a1/src/ermis/client.py +188 -0
- ermis_voice-0.1.0a1/src/ermis/errors.py +77 -0
- ermis_voice-0.1.0a1/src/ermis/events.py +250 -0
- ermis_voice-0.1.0a1/src/ermis/options.py +118 -0
- ermis_voice-0.1.0a1/src/ermis/py.typed +0 -0
- ermis_voice-0.1.0a1/tests/test_example.py +66 -0
- ermis_voice-0.1.0a1/tests/test_sdk.py +264 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: ruff check .
|
|
21
|
+
- run: ruff format --check .
|
|
22
|
+
- run: python -m pytest -q
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Publishes to PyPI when a GitHub release is published (trusted publishing, no token in the repo).
|
|
2
|
+
# One-time setup on PyPI: add a pending publisher for project "ermis-voice" with
|
|
3
|
+
# owner Ermisai, repository ermis-voice-sdk, workflow publish.yml and environment pypi.
|
|
4
|
+
name: Publish to PyPI
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: pip install build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
permissions:
|
|
30
|
+
id-token: write
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/download-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0a1 (prévia)
|
|
4
|
+
|
|
5
|
+
- Primeira versão pública do SDK Python da Ermis Voice API: sessões (`twilio`, `browser`, `websocket`), canal de controle com eventos tipados, `decide`, `extract_slot`, mensagens de espera (`WaitingOptions`, com frases por intenção e pausa antes da resposta), `transcription_priority`, TwiML pronto e exceções por código de erro.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ermis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ermis-voice
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: SDK Python da Ermis Voice API: crie sessões de voz e conduza a conversa pelo canal de controle.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Ermisai/ermis-voice-sdk
|
|
6
|
+
Project-URL: Documentation, https://github.com/Ermisai/ermis-voice-sdk/blob/main/docs/api.md
|
|
7
|
+
Project-URL: Issues, https://github.com/Ermisai/ermis-voice-sdk/issues
|
|
8
|
+
Author: Ermis
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ermis,speech,telephony,twilio,voice,voice-agent
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: websockets>=13
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.25; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: python-multipart>=0.0.9; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff==0.8.4; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Ermis Voice SDK para Python
|
|
31
|
+
|
|
32
|
+
[](https://github.com/Ermisai/ermis-voice-sdk/actions/workflows/ci.yml)
|
|
33
|
+
|
|
34
|
+
> **Prévia (0.1.0a1).** A Voice API ainda está em desenvolvimento; nomes e campos podem mudar até o lançamento.
|
|
35
|
+
|
|
36
|
+
O SDK liga o **seu backend** à **Ermis Voice API**. Ele cria sessões, abre o canal de controle e entrega os eventos da ligação como objetos Python, com métodos para mandar o agente agir.
|
|
37
|
+
|
|
38
|
+
## O que é de quem
|
|
39
|
+
|
|
40
|
+
| | A **Voice API** (nos servidores da Ermis) | O **SDK** (no seu servidor) | **Você** |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| Áudio da ligação | Recebe do Twilio/navegador, detecta fala e interrupções | Não toca no áudio (exceto no transporte `websocket`, onde só repassa quadros) | Conecta o áudio à Ermis (TwiML, navegador) |
|
|
43
|
+
| O que o cliente disse | Transcreve e avalia (`transcript`, `assessment`) | Converte em `Transcript` | Decide o que fazer |
|
|
44
|
+
| O que o cliente quer | Classifica intenção, extrai dados (`decision`, `slot`) | `Decision`; `decide()` e `extract_slot()` viram chamadas com retorno | Usa para escolher a resposta |
|
|
45
|
+
| Resposta | Sintetiza e fala, para no barge-in | `speak()`, `say()` | Escreve o texto (seu roteiro ou sua IA) |
|
|
46
|
+
| Espera | Fala frases de espera e aplica o tempo limite | `WaitingOptions`, `thinking()` | Configura as frases |
|
|
47
|
+
| Cobrança | Mede por segundo, desconta créditos | `usage()`, `balance()`, exceções de crédito | — |
|
|
48
|
+
|
|
49
|
+
Resumindo: **a Voice API conduz a ligação; o SDK é o controle remoto; a inteligência do negócio é sua.** Tudo o que o SDK faz também pode ser feito com HTTP e WebSocket puros, seguindo a [documentação da API](https://github.com/Ermisai/ermis-voice-sdk/blob/main/docs/api.md). O SDK só tira o trabalho repetitivo: autenticação, montagem do JSON, ids de correlação, tipos, erros e o TwiML.
|
|
50
|
+
|
|
51
|
+
## Instalação
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install ermis-voice # Python 3.10+
|
|
55
|
+
export ERMIS_API_KEY=ek_test_...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
O pacote se chama `ermis-voice` no pip e é importado como `ermis`:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from ermis import Ermis
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Para instalar direto do GitHub (por exemplo, uma versão ainda não publicada no PyPI):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "git+https://github.com/Ermisai/ermis-voice-sdk.git"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Durante a prévia, a chave e a URL da Voice API são entregues pela equipe da Ermis. Defina `ERMIS_API_URL` se ela for diferente de `https://api.ermis.ai`.
|
|
71
|
+
|
|
72
|
+
Chaves `ek_test_…` usam o Sandbox; `ek_live_…`, produção. **Use a chave só no servidor.**
|
|
73
|
+
|
|
74
|
+
## Início rápido (Twilio)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
|
|
79
|
+
from fastapi import FastAPI, Form, Response
|
|
80
|
+
from ermis import Ermis, SessionStarted, Decision
|
|
81
|
+
|
|
82
|
+
app = FastAPI()
|
|
83
|
+
ermis = Ermis() # lê ERMIS_API_KEY
|
|
84
|
+
|
|
85
|
+
@app.post("/twilio/voz")
|
|
86
|
+
async def ligacao(CallSid: str = Form(...)):
|
|
87
|
+
session = await ermis.sessions.create(transport="twilio", metadata={"call_sid": CallSid})
|
|
88
|
+
asyncio.create_task(conversar(session))
|
|
89
|
+
return Response(session.twiml(), media_type="application/xml") # o áudio vai direto para a Ermis
|
|
90
|
+
|
|
91
|
+
async def conversar(session):
|
|
92
|
+
async with ermis.connect(session) as call:
|
|
93
|
+
async for event in call:
|
|
94
|
+
if isinstance(event, SessionStarted):
|
|
95
|
+
await call.speak("Olá! Como posso ajudar?", calibrate=True)
|
|
96
|
+
elif isinstance(event, Decision):
|
|
97
|
+
await call.speak(await sua_ia(event))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Exemplo completo, com respostas rápidas, confirmação, desistência e despedida: [`examples/atendente_twilio.py`](https://github.com/Ermisai/ermis-voice-sdk/blob/main/examples/atendente_twilio.py).
|
|
101
|
+
|
|
102
|
+
## Referência
|
|
103
|
+
|
|
104
|
+
### `Ermis(api_key=None, *, base_url=None, timeout=10.0, http_client=None)`
|
|
105
|
+
|
|
106
|
+
Cliente assíncrono. Sem `api_key`, lê `ERMIS_API_KEY`; sem `base_url`, lê `ERMIS_API_URL` ou usa `https://api.ermis.ai`. Feche com `await ermis.aclose()` ou use `async with Ermis() as ermis`.
|
|
107
|
+
|
|
108
|
+
| Método | Chamada da API | Retorno |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `await ermis.sessions.create(**opções)` | `POST /v1/voice/sessions` | `Session` |
|
|
111
|
+
| `await ermis.sessions.get(session_id)` | `GET /v1/voice/sessions/{id}` | `dict` |
|
|
112
|
+
| `await ermis.sessions.end(session_id)` | `DELETE /v1/voice/sessions/{id}` | `None` |
|
|
113
|
+
| `await ermis.usage(from_=, to=, group_by="day")` | `GET /v1/usage` | `dict` |
|
|
114
|
+
| `await ermis.balance()` | `GET /v1/account/balance` | `dict` |
|
|
115
|
+
| `ermis.connect(session_or_control_url)` | WebSocket `control_url` | `Call` (use com `async with`) |
|
|
116
|
+
|
|
117
|
+
**Opções de `sessions.create`** (todas opcionais; o que você não passar usa o padrão da API):
|
|
118
|
+
`transport` (`"twilio"`, `"browser"`, `"websocket"`), `audio_format` (`"mulaw_8k"`, `"pcm16_48k"`), `language`, `voice`, `auto_decide`, `transcription_priority` (`"balanced"`, `"latency"`, `"accuracy"`), `vad=VadOptions(min_silence_ms=…)`, `barge_in=BargeInOptions(enabled=…, min_speech_ms=…)`, `waiting=WaitingOptions(…)`, `max_duration_s`, `metadata`.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from ermis import WaitingOptions
|
|
122
|
+
|
|
123
|
+
waiting = WaitingOptions(
|
|
124
|
+
filler_after_ms=800, # None desliga a frase de espera
|
|
125
|
+
filler_messages=["Certo, um momento."],
|
|
126
|
+
filler_by_intent={"complaint": ["Entendo, sinto muito por isso."]}, # frase pela intenção da fala
|
|
127
|
+
gap_after_ms=600, # pausa antes da sua resposta; None desliga
|
|
128
|
+
patience_after_ms=3500,
|
|
129
|
+
patience_every_ms=8000,
|
|
130
|
+
patience_messages=["Ainda estou verificando…"],
|
|
131
|
+
timeout_ms=20000, # None: espera sem limite
|
|
132
|
+
timeout_message="Desculpe, não consegui essa informação agora.",
|
|
133
|
+
timeout_action="apologize", # ou "end"
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `Session`
|
|
138
|
+
|
|
139
|
+
`session_id`, `audio_url`, `control_url`, `session_token`, e:
|
|
140
|
+
|
|
141
|
+
- `session.twiml()`: TwiML pronto para o transporte `twilio`, com o token escapado.
|
|
142
|
+
- `session.browser_credentials()`: `{"audio_url", "session_token"}`, o que entregar ao navegador no transporte `browser`. A chave de API nunca sai do servidor.
|
|
143
|
+
|
|
144
|
+
### `Call`: o canal de controle
|
|
145
|
+
|
|
146
|
+
`async for event in call` recebe os eventos até `SessionEnded`. Os comandos podem ser chamados a qualquer momento, inclusive enquanto você trata um evento.
|
|
147
|
+
|
|
148
|
+
| Método | Mensagem enviada | Retorno |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `await call.speak(text, *, voice=, allow_barge_in=, calibrate=, id=)` | `speak` | o `id` da fala, na hora |
|
|
151
|
+
| `await call.say(text, **mesmas_opções)` | `speak` e espera | `True` se falou tudo, `False` se o cliente interrompeu |
|
|
152
|
+
| `await call.interrupt()` | `interrupt` | — |
|
|
153
|
+
| `await call.decide(text, custom_intents=[CustomIntent(key, label, description)])` | `decide` | `Decision` |
|
|
154
|
+
| `await call.extract_slot(text, name, type="text", *, description=)` | `extract_slot` | `SlotResult` |
|
|
155
|
+
| `await call.thinking(message=None, *, timeout_ms=None)` | `thinking` | — |
|
|
156
|
+
| `await call.done_processing()` | `done_processing` | — |
|
|
157
|
+
| `await call.end()` | `end` | — |
|
|
158
|
+
| `await call.send_audio(frame)` | quadro binário (só `websocket`) | — |
|
|
159
|
+
| `await call.playback_finished(speak_id)` | `playback_finished` (só `websocket`) | — |
|
|
160
|
+
| `await call.send(dict)` | qualquer mensagem crua | — |
|
|
161
|
+
|
|
162
|
+
`decide()` e `extract_slot()` geram o `id`, esperam a resposta com esse `id` (padrão: 10 s) e devolvem o resultado. Essas respostas **não** aparecem na iteração. Depois do fim, `call.ended` guarda o `SessionEnded`.
|
|
163
|
+
|
|
164
|
+
### Eventos
|
|
165
|
+
|
|
166
|
+
| Classe | Evento da API | Campos |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `SessionStarted` | `session.started` | `session_id`, `limits` |
|
|
169
|
+
| `SpeechStarted` / `SpeechEnded` | `speech.started` / `speech.ended` | `duration_ms` |
|
|
170
|
+
| `Transcript` | `transcript` | `utterance_id`, `text`, `uncertain`, `assessment`, `latency_ms` |
|
|
171
|
+
| `Decision` | `decision` | `intent`, `action`, `is_escape`, `complexity`, `custom_intent`, `extracted`, `utterance_id`, `id` |
|
|
172
|
+
| `SlotResult` | `slot` | `id`, `value`, `status`, `found` |
|
|
173
|
+
| `SpeakStarted` / `SpeakDone` | `speak.started` / `speak.done` | `id`, `chars` |
|
|
174
|
+
| `BargeIn` | `barge_in` | `id` |
|
|
175
|
+
| `WaitingSpoken` | `waiting` | `utterance_id`, `kind`, `text` |
|
|
176
|
+
| `ReplyTimeout` | `reply_timeout` | `utterance_id`, `action` |
|
|
177
|
+
| `Usage` | `usage` | `billed_seconds`, `credits`, `balance` |
|
|
178
|
+
| `WarningEvent` | `warning` | `code`, `message` |
|
|
179
|
+
| `ErrorEvent` | `error` | `code`, `message` |
|
|
180
|
+
| `SessionEnded` | `session.ended` | `reason`, `billed_seconds`, `credits` |
|
|
181
|
+
| `Audio` | quadro binário (só `websocket`) | `data` |
|
|
182
|
+
| `UnknownEvent` | qualquer outro | — |
|
|
183
|
+
|
|
184
|
+
Todo evento tem `type` e `raw` (o JSON original). Eventos novos da API chegam como `UnknownEvent` em vez de quebrar o seu código.
|
|
185
|
+
|
|
186
|
+
`Transcript.assessment` traz `ignore`, `ask_to_repeat`, `unproductive`, `confirm` e o atalho `usable` (nem `ignore`, nem `ask_to_repeat`, nem `unproductive`).
|
|
187
|
+
|
|
188
|
+
### Erros
|
|
189
|
+
|
|
190
|
+
O mesmo erro chega como status HTTP ou código de fechamento do WebSocket; o SDK levanta a mesma exceção nos dois casos. Todas herdam de `ErmisError` (`message`, `code`, `status`).
|
|
191
|
+
|
|
192
|
+
| Exceção | HTTP / WebSocket |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `AuthenticationError` | 401 / 4001 |
|
|
195
|
+
| `InsufficientCreditsError` | 402 / 4002 |
|
|
196
|
+
| `ChannelLimitError` | 429 / 4029 |
|
|
197
|
+
| `InvalidRequestError` | 400 / 4400 (também quando `decide`/`extract_slot` recebem `error`) |
|
|
198
|
+
| `SessionTimeoutError` | 4008 (60 s sem áudio) |
|
|
199
|
+
| `MaxDurationError` | 4013 |
|
|
200
|
+
| `APIError` | 500 / 1011 |
|
|
201
|
+
| `ConnectionClosedError` | o canal fechou antes de `session.ended` |
|
|
202
|
+
|
|
203
|
+
## Outros transportes
|
|
204
|
+
|
|
205
|
+
**Navegador.** Crie a sessão com `transport="browser", audio_format="pcm16_48k"` e envie `session.browser_credentials()` ao navegador. O navegador abre `audio_url?session_token=…` e troca áudio direto com a Ermis; o seu backend conduz a conversa com `ermis.connect(session)`, como no Twilio.
|
|
206
|
+
|
|
207
|
+
**Seu próprio WebSocket.** Com `transport="websocket"`, o mesmo `Call` leva áudio e controle: envie com `call.send_audio(frame)`, receba `Audio` na iteração e avise o fim de cada fala com `call.playback_finished(speak_id)`.
|
|
208
|
+
|
|
209
|
+
## Documentação
|
|
210
|
+
|
|
211
|
+
- [Referência da Voice API](https://github.com/Ermisai/ermis-voice-sdk/blob/main/docs/api.md): sessões, canal de controle, decisões, mensagens de espera, áudio e erros.
|
|
212
|
+
- Na plataforma da Ermis: **Documentação API → Ermis Voice** e a aba **Ligação** da Área de Teste, para conversar com o agente pelo microfone.
|
|
213
|
+
|
|
214
|
+
## Desenvolvimento
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
uv venv && uv pip install -e ".[dev]"
|
|
218
|
+
.venv/bin/python -m pytest -q
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Os testes rodam contra uma Voice API simulada (REST com `httpx.MockTransport`, controle com um servidor WebSocket local).
|
|
222
|
+
|
|
223
|
+
## Licença
|
|
224
|
+
|
|
225
|
+
MIT. Veja [LICENSE](https://github.com/Ermisai/ermis-voice-sdk/blob/main/LICENSE).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Ermis Voice SDK para Python
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Ermisai/ermis-voice-sdk/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
> **Prévia (0.1.0a1).** A Voice API ainda está em desenvolvimento; nomes e campos podem mudar até o lançamento.
|
|
6
|
+
|
|
7
|
+
O SDK liga o **seu backend** à **Ermis Voice API**. Ele cria sessões, abre o canal de controle e entrega os eventos da ligação como objetos Python, com métodos para mandar o agente agir.
|
|
8
|
+
|
|
9
|
+
## O que é de quem
|
|
10
|
+
|
|
11
|
+
| | A **Voice API** (nos servidores da Ermis) | O **SDK** (no seu servidor) | **Você** |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| Áudio da ligação | Recebe do Twilio/navegador, detecta fala e interrupções | Não toca no áudio (exceto no transporte `websocket`, onde só repassa quadros) | Conecta o áudio à Ermis (TwiML, navegador) |
|
|
14
|
+
| O que o cliente disse | Transcreve e avalia (`transcript`, `assessment`) | Converte em `Transcript` | Decide o que fazer |
|
|
15
|
+
| O que o cliente quer | Classifica intenção, extrai dados (`decision`, `slot`) | `Decision`; `decide()` e `extract_slot()` viram chamadas com retorno | Usa para escolher a resposta |
|
|
16
|
+
| Resposta | Sintetiza e fala, para no barge-in | `speak()`, `say()` | Escreve o texto (seu roteiro ou sua IA) |
|
|
17
|
+
| Espera | Fala frases de espera e aplica o tempo limite | `WaitingOptions`, `thinking()` | Configura as frases |
|
|
18
|
+
| Cobrança | Mede por segundo, desconta créditos | `usage()`, `balance()`, exceções de crédito | — |
|
|
19
|
+
|
|
20
|
+
Resumindo: **a Voice API conduz a ligação; o SDK é o controle remoto; a inteligência do negócio é sua.** Tudo o que o SDK faz também pode ser feito com HTTP e WebSocket puros, seguindo a [documentação da API](https://github.com/Ermisai/ermis-voice-sdk/blob/main/docs/api.md). O SDK só tira o trabalho repetitivo: autenticação, montagem do JSON, ids de correlação, tipos, erros e o TwiML.
|
|
21
|
+
|
|
22
|
+
## Instalação
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ermis-voice # Python 3.10+
|
|
26
|
+
export ERMIS_API_KEY=ek_test_...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
O pacote se chama `ermis-voice` no pip e é importado como `ermis`:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ermis import Ermis
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Para instalar direto do GitHub (por exemplo, uma versão ainda não publicada no PyPI):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install "git+https://github.com/Ermisai/ermis-voice-sdk.git"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Durante a prévia, a chave e a URL da Voice API são entregues pela equipe da Ermis. Defina `ERMIS_API_URL` se ela for diferente de `https://api.ermis.ai`.
|
|
42
|
+
|
|
43
|
+
Chaves `ek_test_…` usam o Sandbox; `ek_live_…`, produção. **Use a chave só no servidor.**
|
|
44
|
+
|
|
45
|
+
## Início rápido (Twilio)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
|
|
50
|
+
from fastapi import FastAPI, Form, Response
|
|
51
|
+
from ermis import Ermis, SessionStarted, Decision
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
ermis = Ermis() # lê ERMIS_API_KEY
|
|
55
|
+
|
|
56
|
+
@app.post("/twilio/voz")
|
|
57
|
+
async def ligacao(CallSid: str = Form(...)):
|
|
58
|
+
session = await ermis.sessions.create(transport="twilio", metadata={"call_sid": CallSid})
|
|
59
|
+
asyncio.create_task(conversar(session))
|
|
60
|
+
return Response(session.twiml(), media_type="application/xml") # o áudio vai direto para a Ermis
|
|
61
|
+
|
|
62
|
+
async def conversar(session):
|
|
63
|
+
async with ermis.connect(session) as call:
|
|
64
|
+
async for event in call:
|
|
65
|
+
if isinstance(event, SessionStarted):
|
|
66
|
+
await call.speak("Olá! Como posso ajudar?", calibrate=True)
|
|
67
|
+
elif isinstance(event, Decision):
|
|
68
|
+
await call.speak(await sua_ia(event))
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Exemplo completo, com respostas rápidas, confirmação, desistência e despedida: [`examples/atendente_twilio.py`](https://github.com/Ermisai/ermis-voice-sdk/blob/main/examples/atendente_twilio.py).
|
|
72
|
+
|
|
73
|
+
## Referência
|
|
74
|
+
|
|
75
|
+
### `Ermis(api_key=None, *, base_url=None, timeout=10.0, http_client=None)`
|
|
76
|
+
|
|
77
|
+
Cliente assíncrono. Sem `api_key`, lê `ERMIS_API_KEY`; sem `base_url`, lê `ERMIS_API_URL` ou usa `https://api.ermis.ai`. Feche com `await ermis.aclose()` ou use `async with Ermis() as ermis`.
|
|
78
|
+
|
|
79
|
+
| Método | Chamada da API | Retorno |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `await ermis.sessions.create(**opções)` | `POST /v1/voice/sessions` | `Session` |
|
|
82
|
+
| `await ermis.sessions.get(session_id)` | `GET /v1/voice/sessions/{id}` | `dict` |
|
|
83
|
+
| `await ermis.sessions.end(session_id)` | `DELETE /v1/voice/sessions/{id}` | `None` |
|
|
84
|
+
| `await ermis.usage(from_=, to=, group_by="day")` | `GET /v1/usage` | `dict` |
|
|
85
|
+
| `await ermis.balance()` | `GET /v1/account/balance` | `dict` |
|
|
86
|
+
| `ermis.connect(session_or_control_url)` | WebSocket `control_url` | `Call` (use com `async with`) |
|
|
87
|
+
|
|
88
|
+
**Opções de `sessions.create`** (todas opcionais; o que você não passar usa o padrão da API):
|
|
89
|
+
`transport` (`"twilio"`, `"browser"`, `"websocket"`), `audio_format` (`"mulaw_8k"`, `"pcm16_48k"`), `language`, `voice`, `auto_decide`, `transcription_priority` (`"balanced"`, `"latency"`, `"accuracy"`), `vad=VadOptions(min_silence_ms=…)`, `barge_in=BargeInOptions(enabled=…, min_speech_ms=…)`, `waiting=WaitingOptions(…)`, `max_duration_s`, `metadata`.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from ermis import WaitingOptions
|
|
93
|
+
|
|
94
|
+
waiting = WaitingOptions(
|
|
95
|
+
filler_after_ms=800, # None desliga a frase de espera
|
|
96
|
+
filler_messages=["Certo, um momento."],
|
|
97
|
+
filler_by_intent={"complaint": ["Entendo, sinto muito por isso."]}, # frase pela intenção da fala
|
|
98
|
+
gap_after_ms=600, # pausa antes da sua resposta; None desliga
|
|
99
|
+
patience_after_ms=3500,
|
|
100
|
+
patience_every_ms=8000,
|
|
101
|
+
patience_messages=["Ainda estou verificando…"],
|
|
102
|
+
timeout_ms=20000, # None: espera sem limite
|
|
103
|
+
timeout_message="Desculpe, não consegui essa informação agora.",
|
|
104
|
+
timeout_action="apologize", # ou "end"
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `Session`
|
|
109
|
+
|
|
110
|
+
`session_id`, `audio_url`, `control_url`, `session_token`, e:
|
|
111
|
+
|
|
112
|
+
- `session.twiml()`: TwiML pronto para o transporte `twilio`, com o token escapado.
|
|
113
|
+
- `session.browser_credentials()`: `{"audio_url", "session_token"}`, o que entregar ao navegador no transporte `browser`. A chave de API nunca sai do servidor.
|
|
114
|
+
|
|
115
|
+
### `Call`: o canal de controle
|
|
116
|
+
|
|
117
|
+
`async for event in call` recebe os eventos até `SessionEnded`. Os comandos podem ser chamados a qualquer momento, inclusive enquanto você trata um evento.
|
|
118
|
+
|
|
119
|
+
| Método | Mensagem enviada | Retorno |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `await call.speak(text, *, voice=, allow_barge_in=, calibrate=, id=)` | `speak` | o `id` da fala, na hora |
|
|
122
|
+
| `await call.say(text, **mesmas_opções)` | `speak` e espera | `True` se falou tudo, `False` se o cliente interrompeu |
|
|
123
|
+
| `await call.interrupt()` | `interrupt` | — |
|
|
124
|
+
| `await call.decide(text, custom_intents=[CustomIntent(key, label, description)])` | `decide` | `Decision` |
|
|
125
|
+
| `await call.extract_slot(text, name, type="text", *, description=)` | `extract_slot` | `SlotResult` |
|
|
126
|
+
| `await call.thinking(message=None, *, timeout_ms=None)` | `thinking` | — |
|
|
127
|
+
| `await call.done_processing()` | `done_processing` | — |
|
|
128
|
+
| `await call.end()` | `end` | — |
|
|
129
|
+
| `await call.send_audio(frame)` | quadro binário (só `websocket`) | — |
|
|
130
|
+
| `await call.playback_finished(speak_id)` | `playback_finished` (só `websocket`) | — |
|
|
131
|
+
| `await call.send(dict)` | qualquer mensagem crua | — |
|
|
132
|
+
|
|
133
|
+
`decide()` e `extract_slot()` geram o `id`, esperam a resposta com esse `id` (padrão: 10 s) e devolvem o resultado. Essas respostas **não** aparecem na iteração. Depois do fim, `call.ended` guarda o `SessionEnded`.
|
|
134
|
+
|
|
135
|
+
### Eventos
|
|
136
|
+
|
|
137
|
+
| Classe | Evento da API | Campos |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `SessionStarted` | `session.started` | `session_id`, `limits` |
|
|
140
|
+
| `SpeechStarted` / `SpeechEnded` | `speech.started` / `speech.ended` | `duration_ms` |
|
|
141
|
+
| `Transcript` | `transcript` | `utterance_id`, `text`, `uncertain`, `assessment`, `latency_ms` |
|
|
142
|
+
| `Decision` | `decision` | `intent`, `action`, `is_escape`, `complexity`, `custom_intent`, `extracted`, `utterance_id`, `id` |
|
|
143
|
+
| `SlotResult` | `slot` | `id`, `value`, `status`, `found` |
|
|
144
|
+
| `SpeakStarted` / `SpeakDone` | `speak.started` / `speak.done` | `id`, `chars` |
|
|
145
|
+
| `BargeIn` | `barge_in` | `id` |
|
|
146
|
+
| `WaitingSpoken` | `waiting` | `utterance_id`, `kind`, `text` |
|
|
147
|
+
| `ReplyTimeout` | `reply_timeout` | `utterance_id`, `action` |
|
|
148
|
+
| `Usage` | `usage` | `billed_seconds`, `credits`, `balance` |
|
|
149
|
+
| `WarningEvent` | `warning` | `code`, `message` |
|
|
150
|
+
| `ErrorEvent` | `error` | `code`, `message` |
|
|
151
|
+
| `SessionEnded` | `session.ended` | `reason`, `billed_seconds`, `credits` |
|
|
152
|
+
| `Audio` | quadro binário (só `websocket`) | `data` |
|
|
153
|
+
| `UnknownEvent` | qualquer outro | — |
|
|
154
|
+
|
|
155
|
+
Todo evento tem `type` e `raw` (o JSON original). Eventos novos da API chegam como `UnknownEvent` em vez de quebrar o seu código.
|
|
156
|
+
|
|
157
|
+
`Transcript.assessment` traz `ignore`, `ask_to_repeat`, `unproductive`, `confirm` e o atalho `usable` (nem `ignore`, nem `ask_to_repeat`, nem `unproductive`).
|
|
158
|
+
|
|
159
|
+
### Erros
|
|
160
|
+
|
|
161
|
+
O mesmo erro chega como status HTTP ou código de fechamento do WebSocket; o SDK levanta a mesma exceção nos dois casos. Todas herdam de `ErmisError` (`message`, `code`, `status`).
|
|
162
|
+
|
|
163
|
+
| Exceção | HTTP / WebSocket |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `AuthenticationError` | 401 / 4001 |
|
|
166
|
+
| `InsufficientCreditsError` | 402 / 4002 |
|
|
167
|
+
| `ChannelLimitError` | 429 / 4029 |
|
|
168
|
+
| `InvalidRequestError` | 400 / 4400 (também quando `decide`/`extract_slot` recebem `error`) |
|
|
169
|
+
| `SessionTimeoutError` | 4008 (60 s sem áudio) |
|
|
170
|
+
| `MaxDurationError` | 4013 |
|
|
171
|
+
| `APIError` | 500 / 1011 |
|
|
172
|
+
| `ConnectionClosedError` | o canal fechou antes de `session.ended` |
|
|
173
|
+
|
|
174
|
+
## Outros transportes
|
|
175
|
+
|
|
176
|
+
**Navegador.** Crie a sessão com `transport="browser", audio_format="pcm16_48k"` e envie `session.browser_credentials()` ao navegador. O navegador abre `audio_url?session_token=…` e troca áudio direto com a Ermis; o seu backend conduz a conversa com `ermis.connect(session)`, como no Twilio.
|
|
177
|
+
|
|
178
|
+
**Seu próprio WebSocket.** Com `transport="websocket"`, o mesmo `Call` leva áudio e controle: envie com `call.send_audio(frame)`, receba `Audio` na iteração e avise o fim de cada fala com `call.playback_finished(speak_id)`.
|
|
179
|
+
|
|
180
|
+
## Documentação
|
|
181
|
+
|
|
182
|
+
- [Referência da Voice API](https://github.com/Ermisai/ermis-voice-sdk/blob/main/docs/api.md): sessões, canal de controle, decisões, mensagens de espera, áudio e erros.
|
|
183
|
+
- Na plataforma da Ermis: **Documentação API → Ermis Voice** e a aba **Ligação** da Área de Teste, para conversar com o agente pelo microfone.
|
|
184
|
+
|
|
185
|
+
## Desenvolvimento
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
uv venv && uv pip install -e ".[dev]"
|
|
189
|
+
.venv/bin/python -m pytest -q
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Os testes rodam contra uma Voice API simulada (REST com `httpx.MockTransport`, controle com um servidor WebSocket local).
|
|
193
|
+
|
|
194
|
+
## Licença
|
|
195
|
+
|
|
196
|
+
MIT. Veja [LICENSE](https://github.com/Ermisai/ermis-voice-sdk/blob/main/LICENSE).
|