scriptivox 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.
- scriptivox/__init__.py +145 -0
- scriptivox/_async_client.py +428 -0
- scriptivox/_client.py +437 -0
- scriptivox/_common.py +293 -0
- scriptivox/_error_codes.py +190 -0
- scriptivox/_exceptions.py +196 -0
- scriptivox/_types.py +473 -0
- scriptivox/_version.py +1 -0
- scriptivox/_webhooks.py +108 -0
- scriptivox/py.typed +0 -0
- scriptivox-0.1.0.dist-info/METADATA +235 -0
- scriptivox-0.1.0.dist-info/RECORD +14 -0
- scriptivox-0.1.0.dist-info/WHEEL +4 -0
- scriptivox-0.1.0.dist-info/licenses/LICENSE +21 -0
scriptivox/_client.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""The synchronous client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Union, cast, overload
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _common as c
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIError,
|
|
15
|
+
APITimeoutError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ScriptivoxError,
|
|
18
|
+
TranscriptionFailedError,
|
|
19
|
+
UploadFailedError,
|
|
20
|
+
WaitTimeoutError,
|
|
21
|
+
)
|
|
22
|
+
from ._types import (
|
|
23
|
+
Balance,
|
|
24
|
+
FeedbackResult,
|
|
25
|
+
Transcription,
|
|
26
|
+
TranscriptionCancelled,
|
|
27
|
+
TranscriptionCreated,
|
|
28
|
+
TranscriptionPage,
|
|
29
|
+
TranscriptionStatus,
|
|
30
|
+
TranscriptionSummary,
|
|
31
|
+
Upload,
|
|
32
|
+
WebhookSendResult,
|
|
33
|
+
)
|
|
34
|
+
from ._webhooks import HeadersLike, WebhookEvent, verify_webhook
|
|
35
|
+
|
|
36
|
+
from typing import Literal, TypedDict
|
|
37
|
+
|
|
38
|
+
_MISSING: Any = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UploadResult(TypedDict):
|
|
42
|
+
upload_id: str
|
|
43
|
+
#: The filename the API stored (after making it ASCII-safe).
|
|
44
|
+
filename: str
|
|
45
|
+
#: Bytes uploaded.
|
|
46
|
+
size: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_key(api_key: Optional[str]) -> str:
|
|
50
|
+
key = api_key or os.environ.get("SCRIPTIVOX_API_KEY")
|
|
51
|
+
if not key:
|
|
52
|
+
raise ScriptivoxError("No API key: pass api_key= or set the SCRIPTIVOX_API_KEY environment variable.")
|
|
53
|
+
return key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Scriptivox:
|
|
57
|
+
"""The Scriptivox API client (synchronous). For asyncio use AsyncScriptivox.
|
|
58
|
+
|
|
59
|
+
>>> client = Scriptivox() # reads SCRIPTIVOX_API_KEY
|
|
60
|
+
>>> job = client.transcribe(file="meeting.mp3", language="en")
|
|
61
|
+
>>> done = client.wait(job["id"])
|
|
62
|
+
>>> print(done["result"]["full_transcript"])
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
api_key: Optional[str] = None,
|
|
68
|
+
*,
|
|
69
|
+
base_url: Optional[str] = None,
|
|
70
|
+
max_retries: int = c.DEFAULT_MAX_RETRIES,
|
|
71
|
+
timeout: float = c.DEFAULT_TIMEOUT,
|
|
72
|
+
http_client: Optional[httpx.Client] = None,
|
|
73
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._api_key = _resolve_key(api_key)
|
|
76
|
+
self.base_url = c.normalize_base_url(base_url or os.environ.get("SCRIPTIVOX_BASE_URL") or c.DEFAULT_BASE_URL)
|
|
77
|
+
self.max_retries = max_retries
|
|
78
|
+
self.timeout = timeout
|
|
79
|
+
self._default_headers = dict(default_headers or {})
|
|
80
|
+
self._owns_client = http_client is None
|
|
81
|
+
self._http = http_client or httpx.Client(follow_redirects=False)
|
|
82
|
+
|
|
83
|
+
# ─── lifecycle ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
if self._owns_client:
|
|
87
|
+
self._http.close()
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> "Scriptivox":
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, *exc: Any) -> None:
|
|
93
|
+
self.close()
|
|
94
|
+
|
|
95
|
+
# ─── transport ────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def _send(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
idempotent: bool,
|
|
103
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
104
|
+
body: Any = _MISSING,
|
|
105
|
+
idempotency_key: Optional[str] = None,
|
|
106
|
+
text: bool = False,
|
|
107
|
+
timeout: Optional[float] = None,
|
|
108
|
+
max_retries: Optional[int] = None,
|
|
109
|
+
) -> Any:
|
|
110
|
+
retries = self.max_retries if max_retries is None else max_retries
|
|
111
|
+
headers: Dict[str, str] = {
|
|
112
|
+
**self._default_headers,
|
|
113
|
+
"Authorization": self._api_key,
|
|
114
|
+
"Accept": "text/plain, text/vtt, application/json" if text else "application/json",
|
|
115
|
+
"User-Agent": c.user_agent(),
|
|
116
|
+
}
|
|
117
|
+
if idempotency_key:
|
|
118
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
119
|
+
kwargs: Dict[str, Any] = {}
|
|
120
|
+
if body is not _MISSING:
|
|
121
|
+
kwargs["json"] = body
|
|
122
|
+
attempt = 0
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
response = self._http.request(
|
|
126
|
+
method,
|
|
127
|
+
self.base_url + path,
|
|
128
|
+
params=c.build_query(query or {}),
|
|
129
|
+
headers=headers,
|
|
130
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
131
|
+
**kwargs,
|
|
132
|
+
)
|
|
133
|
+
except httpx.TimeoutException as exc:
|
|
134
|
+
err: Exception = APITimeoutError(f"No answer from {method} {path} in time.")
|
|
135
|
+
err.__cause__ = exc
|
|
136
|
+
except httpx.TransportError as exc:
|
|
137
|
+
err = APIConnectionError(f"Could not reach the Scriptivox API ({method} {path}): {exc}")
|
|
138
|
+
err.__cause__ = exc
|
|
139
|
+
else:
|
|
140
|
+
if response.is_success:
|
|
141
|
+
if text:
|
|
142
|
+
return response.text
|
|
143
|
+
if not response.content:
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
return response.json()
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise APIError(
|
|
149
|
+
status=response.status_code,
|
|
150
|
+
code="INVALID_RESPONSE",
|
|
151
|
+
message=f"The API answered {method} {path} with a body that is not JSON.",
|
|
152
|
+
request_id=response.headers.get("x-request-id"),
|
|
153
|
+
headers=response.headers,
|
|
154
|
+
) from None
|
|
155
|
+
api_err = c.error_from_response(response.status_code, response.text, response.headers)
|
|
156
|
+
delay = c.retry_delay_for(api_err, idempotent, attempt) if attempt < retries else None
|
|
157
|
+
if delay is None:
|
|
158
|
+
raise api_err
|
|
159
|
+
time.sleep(delay)
|
|
160
|
+
attempt += 1
|
|
161
|
+
continue
|
|
162
|
+
if idempotent and attempt < retries:
|
|
163
|
+
time.sleep(c.backoff(attempt))
|
|
164
|
+
attempt += 1
|
|
165
|
+
continue
|
|
166
|
+
raise err
|
|
167
|
+
|
|
168
|
+
def _put(self, upload_url: str, headers: Mapping[str, str], source: c.FileSource, on_progress: Optional[c.ProgressCallback]) -> None:
|
|
169
|
+
attempt = 0
|
|
170
|
+
while True:
|
|
171
|
+
try:
|
|
172
|
+
response = self._http.request(
|
|
173
|
+
"PUT",
|
|
174
|
+
upload_url,
|
|
175
|
+
content=c.counted(source, on_progress),
|
|
176
|
+
headers={**headers, "Content-Length": str(source.size), "User-Agent": c.user_agent()},
|
|
177
|
+
timeout=c.UPLOAD_IDLE_TIMEOUT,
|
|
178
|
+
)
|
|
179
|
+
if response.is_success:
|
|
180
|
+
return
|
|
181
|
+
err: Exception = UploadFailedError(
|
|
182
|
+
f"The file upload was refused with HTTP {response.status_code}.",
|
|
183
|
+
status=response.status_code,
|
|
184
|
+
body=response.text[:2048],
|
|
185
|
+
)
|
|
186
|
+
transient = response.status_code >= 500
|
|
187
|
+
except httpx.TimeoutException as exc:
|
|
188
|
+
err = APITimeoutError("The upload stalled.")
|
|
189
|
+
err.__cause__ = exc
|
|
190
|
+
transient = True
|
|
191
|
+
except httpx.TransportError as exc:
|
|
192
|
+
err = APIConnectionError(f"The upload connection failed: {exc}")
|
|
193
|
+
err.__cause__ = exc
|
|
194
|
+
transient = True
|
|
195
|
+
if not transient or not source.repeatable or attempt >= self.max_retries:
|
|
196
|
+
raise err
|
|
197
|
+
time.sleep(c.backoff(attempt))
|
|
198
|
+
attempt += 1
|
|
199
|
+
|
|
200
|
+
# ─── upload and transcribe ────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def upload(
|
|
203
|
+
self,
|
|
204
|
+
file: c.FileInput,
|
|
205
|
+
*,
|
|
206
|
+
filename: Optional[str] = None,
|
|
207
|
+
size: Optional[int] = None,
|
|
208
|
+
on_progress: Optional[c.ProgressCallback] = None,
|
|
209
|
+
idempotency_key: Optional[str] = None,
|
|
210
|
+
) -> UploadResult:
|
|
211
|
+
"""Upload a local file (POST /v1/upload, then a streamed PUT to the presigned URL).
|
|
212
|
+
|
|
213
|
+
``file`` is a path, bytes, or a binary file object. Large files are streamed, never read
|
|
214
|
+
into memory. Returns the ``upload_id`` for ``transcribe(upload_id=...)``.
|
|
215
|
+
"""
|
|
216
|
+
source = c.resolve_source(file, size)
|
|
217
|
+
raw_name = filename or source.name
|
|
218
|
+
if not raw_name:
|
|
219
|
+
raise ValueError('Uploading bytes or a stream needs a filename with an extension: pass filename="audio.mp3".')
|
|
220
|
+
safe = c.safe_filename(raw_name)
|
|
221
|
+
upload = cast(
|
|
222
|
+
Upload,
|
|
223
|
+
self._send(
|
|
224
|
+
"POST",
|
|
225
|
+
"/v1/upload",
|
|
226
|
+
body={"filename": safe},
|
|
227
|
+
idempotency_key=idempotency_key or str(uuid.uuid4()),
|
|
228
|
+
idempotent=True,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
if upload.get("method", "PUT").upper() != "PUT":
|
|
232
|
+
raise ScriptivoxError(f"The API asked for an upload method this SDK does not know: {upload.get('method')}.")
|
|
233
|
+
self._put(upload["upload_url"], upload.get("headers") or {}, source, on_progress)
|
|
234
|
+
return {"upload_id": upload["upload_id"], "filename": safe, "size": source.size}
|
|
235
|
+
|
|
236
|
+
def transcribe(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
url: Optional[str] = None,
|
|
240
|
+
file: Optional[c.FileInput] = None,
|
|
241
|
+
upload_id: Optional[str] = None,
|
|
242
|
+
language: Optional[str] = None,
|
|
243
|
+
diarize: Optional[bool] = None,
|
|
244
|
+
speaker_count: Optional[int] = None,
|
|
245
|
+
align: Optional[bool] = None,
|
|
246
|
+
webhook_url: Optional[str] = None,
|
|
247
|
+
filename: Optional[str] = None,
|
|
248
|
+
size: Optional[int] = None,
|
|
249
|
+
on_upload_progress: Optional[c.ProgressCallback] = None,
|
|
250
|
+
idempotency_key: Optional[str] = None,
|
|
251
|
+
) -> TranscriptionCreated:
|
|
252
|
+
"""Start a transcription from a public ``url``, a local ``file`` (uploaded first) or an earlier
|
|
253
|
+
``upload_id``. Returns as soon as the job is accepted (status "created"); call wait(id) next.
|
|
254
|
+
|
|
255
|
+
Every call sends an Idempotency-Key (a new UUID unless you pass one), so the SDK's own retries
|
|
256
|
+
never create a second billable job. Omit ``language`` to auto-detect.
|
|
257
|
+
"""
|
|
258
|
+
c.check_one_source(url, file, upload_id)
|
|
259
|
+
if file is not None:
|
|
260
|
+
upload_id = self.upload(file, filename=filename, size=size, on_progress=on_upload_progress)["upload_id"]
|
|
261
|
+
body = c.transcribe_body(
|
|
262
|
+
url,
|
|
263
|
+
upload_id,
|
|
264
|
+
{"language": language, "diarize": diarize, "speaker_count": speaker_count, "align": align, "webhook_url": webhook_url},
|
|
265
|
+
)
|
|
266
|
+
return cast(
|
|
267
|
+
TranscriptionCreated,
|
|
268
|
+
self._send("POST", "/v1/transcribe", body=body, idempotency_key=idempotency_key or str(uuid.uuid4()), idempotent=True),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# ─── read ─────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
@overload
|
|
274
|
+
def get(self, transcription_id: str) -> Transcription: ...
|
|
275
|
+
|
|
276
|
+
@overload
|
|
277
|
+
def get(self, transcription_id: str, *, format: Literal["json"]) -> Transcription: ...
|
|
278
|
+
|
|
279
|
+
@overload
|
|
280
|
+
def get(
|
|
281
|
+
self,
|
|
282
|
+
transcription_id: str,
|
|
283
|
+
*,
|
|
284
|
+
format: Literal["srt", "vtt", "text"],
|
|
285
|
+
max_words: Optional[int] = None,
|
|
286
|
+
max_chars: Optional[int] = None,
|
|
287
|
+
max_duration: Optional[int] = None,
|
|
288
|
+
sentence_aware: Optional[bool] = None,
|
|
289
|
+
include_speakers: Union[bool, Literal["auto", "true", "false"], None] = None,
|
|
290
|
+
strip_chars: Optional[str] = None,
|
|
291
|
+
include_timestamps: Optional[bool] = None,
|
|
292
|
+
) -> str: ...
|
|
293
|
+
|
|
294
|
+
def get(
|
|
295
|
+
self,
|
|
296
|
+
transcription_id: str,
|
|
297
|
+
*,
|
|
298
|
+
format: str = "json",
|
|
299
|
+
max_words: Optional[int] = None,
|
|
300
|
+
max_chars: Optional[int] = None,
|
|
301
|
+
max_duration: Optional[int] = None,
|
|
302
|
+
sentence_aware: Optional[bool] = None,
|
|
303
|
+
include_speakers: Union[bool, str, None] = None,
|
|
304
|
+
strip_chars: Optional[str] = None,
|
|
305
|
+
include_timestamps: Optional[bool] = None,
|
|
306
|
+
) -> Union[Transcription, str]:
|
|
307
|
+
"""GET /v1/transcribe/{id}: the job (format "json"), or the SRT / VTT / text file as a string."""
|
|
308
|
+
query = c.export_query(
|
|
309
|
+
format,
|
|
310
|
+
{
|
|
311
|
+
"max_words": max_words,
|
|
312
|
+
"max_chars": max_chars,
|
|
313
|
+
"max_duration": max_duration,
|
|
314
|
+
"sentence_aware": sentence_aware,
|
|
315
|
+
"include_speakers": include_speakers,
|
|
316
|
+
"strip_chars": strip_chars,
|
|
317
|
+
"include_timestamps": include_timestamps,
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
return cast(
|
|
321
|
+
Union[Transcription, str],
|
|
322
|
+
self._send("GET", f"/v1/transcribe/{c.path_id(transcription_id)}", query=query, idempotent=True, text=format != "json"),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def wait(
|
|
326
|
+
self,
|
|
327
|
+
transcription_id: str,
|
|
328
|
+
*,
|
|
329
|
+
timeout: float = c.DEFAULT_WAIT_TIMEOUT,
|
|
330
|
+
poll_interval: float = c.DEFAULT_POLL_INTERVAL,
|
|
331
|
+
max_poll_interval: float = c.DEFAULT_MAX_POLL_INTERVAL,
|
|
332
|
+
on_poll: Optional[Any] = None,
|
|
333
|
+
) -> Transcription:
|
|
334
|
+
"""Poll until the job ends and return it completed. A failed job raises TranscriptionFailedError
|
|
335
|
+
(its ``code`` is the job's error.code). The wait between polls starts at 2 s and grows to 10 s;
|
|
336
|
+
a 429 waits out its Retry-After. ``timeout`` is in seconds (default 2 hours)."""
|
|
337
|
+
deadline = c.monotonic() + timeout
|
|
338
|
+
interval = min(poll_interval, max_poll_interval)
|
|
339
|
+
while True:
|
|
340
|
+
try:
|
|
341
|
+
job: Transcription = self.get(transcription_id)
|
|
342
|
+
except RateLimitError as exc:
|
|
343
|
+
if exc.retry_after is not None and c.monotonic() + exc.retry_after < deadline:
|
|
344
|
+
time.sleep(exc.retry_after)
|
|
345
|
+
continue
|
|
346
|
+
raise
|
|
347
|
+
if on_poll:
|
|
348
|
+
on_poll(job)
|
|
349
|
+
if job["status"] == "completed":
|
|
350
|
+
return job
|
|
351
|
+
if job["status"] == "failed":
|
|
352
|
+
raise TranscriptionFailedError(job)
|
|
353
|
+
if c.monotonic() + interval > deadline:
|
|
354
|
+
raise WaitTimeoutError(job, timeout)
|
|
355
|
+
time.sleep(interval)
|
|
356
|
+
interval = min(interval * 1.5, max_poll_interval)
|
|
357
|
+
|
|
358
|
+
def list_page(
|
|
359
|
+
self,
|
|
360
|
+
*,
|
|
361
|
+
status: Optional[TranscriptionStatus] = None,
|
|
362
|
+
from_: Optional[Any] = None,
|
|
363
|
+
to: Optional[Any] = None,
|
|
364
|
+
limit: Optional[int] = None,
|
|
365
|
+
order: Optional[Literal["desc", "asc"]] = None,
|
|
366
|
+
cursor: Optional[str] = None,
|
|
367
|
+
) -> TranscriptionPage:
|
|
368
|
+
"""One page of GET /v1/transcriptions. Most callers want list(), which follows the cursor."""
|
|
369
|
+
query = {"status": status, "from": from_, "to": to, "limit": limit, "order": order, "cursor": cursor}
|
|
370
|
+
return cast(TranscriptionPage, self._send("GET", "/v1/transcriptions", query=query, idempotent=True))
|
|
371
|
+
|
|
372
|
+
def list(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
status: Optional[TranscriptionStatus] = None,
|
|
376
|
+
from_: Optional[Any] = None,
|
|
377
|
+
to: Optional[Any] = None,
|
|
378
|
+
limit: Optional[int] = None,
|
|
379
|
+
order: Optional[Literal["desc", "asc"]] = None,
|
|
380
|
+
cursor: Optional[str] = None,
|
|
381
|
+
) -> Iterator[TranscriptionSummary]:
|
|
382
|
+
"""Every matching transcription, newest first, fetched page by page as you iterate.
|
|
383
|
+
``from_`` and ``to`` take a datetime or an ISO string. Items omit ``result``; get(id) has it."""
|
|
384
|
+
while True:
|
|
385
|
+
page = self.list_page(status=status, from_=from_, to=to, limit=limit, order=order, cursor=cursor)
|
|
386
|
+
yield from page["items"]
|
|
387
|
+
cursor = page.get("next_cursor")
|
|
388
|
+
if not cursor:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
# ─── change ───────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
def cancel(self, transcription_id: str) -> TranscriptionCancelled:
|
|
394
|
+
"""Stop a job before it starts transcribing (created, downloading or pending). You are not charged."""
|
|
395
|
+
return cast(TranscriptionCancelled, self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/cancel", idempotent=True))
|
|
396
|
+
|
|
397
|
+
def delete(self, transcription_id: str) -> None:
|
|
398
|
+
"""Delete a finished job and its transcript. Twice is fine; a job still running raises ConflictError (cancel it first)."""
|
|
399
|
+
self._send("DELETE", f"/v1/transcribe/{c.path_id(transcription_id)}", idempotent=True)
|
|
400
|
+
|
|
401
|
+
def balance(self) -> Balance:
|
|
402
|
+
"""Balance, reserved and available amounts in cents, the price per hour and the auto top-up settings."""
|
|
403
|
+
return cast(Balance, self._send("GET", "/v1/balance", idempotent=True))
|
|
404
|
+
|
|
405
|
+
# ─── webhooks and feedback ────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
def resend_webhook(self, transcription_id: str) -> WebhookSendResult:
|
|
408
|
+
"""Send a finished job's final webhook again now, with the same X-Scriptivox-Delivery id."""
|
|
409
|
+
return cast(WebhookSendResult, self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/webhook/resend", idempotent=False))
|
|
410
|
+
|
|
411
|
+
def test_webhook(self, url: Optional[str] = None) -> WebhookSendResult:
|
|
412
|
+
"""Send a signed ``webhook.test`` event to ``url`` (or your default webhook URL) and report the answer."""
|
|
413
|
+
return cast(WebhookSendResult, self._send("POST", "/v1/webhooks/test", body={} if url is None else {"url": url}, idempotent=False))
|
|
414
|
+
|
|
415
|
+
def feedback(
|
|
416
|
+
self,
|
|
417
|
+
transcription_id: str,
|
|
418
|
+
*,
|
|
419
|
+
rating: Optional[int] = None,
|
|
420
|
+
topics: Optional[List[str]] = None,
|
|
421
|
+
comment: Optional[str] = None,
|
|
422
|
+
their_words: Optional[str] = None,
|
|
423
|
+
problem: Optional[bool] = None,
|
|
424
|
+
what_happened: Optional[str] = None,
|
|
425
|
+
) -> FeedbackResult:
|
|
426
|
+
"""Rate a completed job (1–5) and/or report a problem with any job."""
|
|
427
|
+
body = {
|
|
428
|
+
k: v
|
|
429
|
+
for k, v in {"rating": rating, "topics": topics, "comment": comment, "their_words": their_words, "problem": problem, "what_happened": what_happened}.items()
|
|
430
|
+
if v is not None
|
|
431
|
+
}
|
|
432
|
+
return cast(FeedbackResult, self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/feedback", body=body, idempotent=False))
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def verify_webhook(raw_body: Union[bytes, str], headers: HeadersLike, secret: str, *, tolerance: int = 300, now: Optional[float] = None) -> WebhookEvent:
|
|
436
|
+
"""Same as the module-level verify_webhook()."""
|
|
437
|
+
return verify_webhook(raw_body, headers, secret, tolerance=tolerance, now=now)
|