echoact 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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
"""Section 2.10's twelve operations. Twelve, and no thirteenth.
|
|
2
|
+
|
|
3
|
+
The contract table is the public functional surface, so this module registers
|
|
4
|
+
exactly the paths it lists. Anything a client might also find useful --
|
|
5
|
+
deleting a job, editing a document, querying the database -- is absent on
|
|
6
|
+
purpose: F-56 says external document editing, deletion, and full-database
|
|
7
|
+
queries are not supported, and F-61 says MCP adds no capability REST does not
|
|
8
|
+
already expose, which only means anything if REST's own surface is closed.
|
|
9
|
+
|
|
10
|
+
Two constraints shape how the handlers are written.
|
|
11
|
+
|
|
12
|
+
* **N-22.** Acceptance, query, cancellation, and estimate answer within one
|
|
13
|
+
second at p95 and independently of model loading and generation. So no
|
|
14
|
+
handler blocks on the engine. ``submit`` returns as soon as the job is
|
|
15
|
+
accepted, ``cancel`` hands the kill to another thread and answers 202, and
|
|
16
|
+
the only wait in the file is F-88's, which the caller asked for and which
|
|
17
|
+
N-22 excludes from its figure.
|
|
18
|
+
* **N-21.** No list query and no audio retrieval loads an unbounded amount
|
|
19
|
+
into memory. Pages are bounded by 4.1, segment and result rows carry
|
|
20
|
+
counts rather than audio, and both audio routes stream from disk.
|
|
21
|
+
|
|
22
|
+
Capability mapping, from F-61's three separately granted permissions:
|
|
23
|
+
|
|
24
|
+
=========================================== =====================
|
|
25
|
+
Operation Capability
|
|
26
|
+
=========================================== =====================
|
|
27
|
+
status, models (authentication only)
|
|
28
|
+
estimate, create, job state, cancel ``generate``
|
|
29
|
+
segments, segment audio, audio, result ``read_results``
|
|
30
|
+
history list, source-text snapshot ``read_history``
|
|
31
|
+
=========================================== =====================
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from fastapi import APIRouter, Query, Request, Response
|
|
40
|
+
from fastapi.exceptions import RequestValidationError
|
|
41
|
+
from pydantic import ValidationError
|
|
42
|
+
from python_multipart.exceptions import FormParserError
|
|
43
|
+
from starlette.concurrency import run_in_threadpool
|
|
44
|
+
from starlette.datastructures import FormData
|
|
45
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
46
|
+
from starlette.formparsers import MultiPartException
|
|
47
|
+
from starlette.responses import StreamingResponse
|
|
48
|
+
|
|
49
|
+
from ..audio import wav
|
|
50
|
+
from ..config.budget import resolve_budget_from_system
|
|
51
|
+
from ..db.store import JobSummary
|
|
52
|
+
from ..domain import (
|
|
53
|
+
Budget,
|
|
54
|
+
Capability,
|
|
55
|
+
Gender,
|
|
56
|
+
Job,
|
|
57
|
+
JobKind,
|
|
58
|
+
JobState,
|
|
59
|
+
Language,
|
|
60
|
+
RequestPath,
|
|
61
|
+
RetentionMode,
|
|
62
|
+
Segment,
|
|
63
|
+
SpeakingStyle,
|
|
64
|
+
VoiceSettings,
|
|
65
|
+
)
|
|
66
|
+
from ..errors import Code, EchoActError, is_retryable
|
|
67
|
+
from ..jobs.engine import BUSY_RETRY_AFTER_S
|
|
68
|
+
from ..jobs.request import JobRequest, clamp_wait, estimate
|
|
69
|
+
from ..models.registry import ModelState
|
|
70
|
+
from ..policy import (
|
|
71
|
+
API_VERSION,
|
|
72
|
+
BOUNDED_WAIT_DEFAULT_S,
|
|
73
|
+
IDEMPOTENCY_TTL_S,
|
|
74
|
+
LIST_PAGE_DEFAULT,
|
|
75
|
+
LIST_PAGE_MAX,
|
|
76
|
+
MAX_CONCURRENT_GENERATION,
|
|
77
|
+
MAX_IMPORT_FILE_BYTES,
|
|
78
|
+
MAX_INPUT_CODEPOINTS,
|
|
79
|
+
MAX_REQUEST_BODY_BYTES,
|
|
80
|
+
ONEOFF_RESULT_TTL_S,
|
|
81
|
+
RATE_GENERATION_PER_MIN,
|
|
82
|
+
RATE_OTHER_PER_MIN,
|
|
83
|
+
TEMPO_DEFAULT,
|
|
84
|
+
TEMPO_MAX,
|
|
85
|
+
TEMPO_MIN,
|
|
86
|
+
)
|
|
87
|
+
from ..text.loader import load_bytes
|
|
88
|
+
from ..text.sniff import SELECTABLE_ENCODINGS, SUPPORTED_FORMATS
|
|
89
|
+
from ..util import ids
|
|
90
|
+
from .deps import (
|
|
91
|
+
CANCEL_ACK_GRACE_S,
|
|
92
|
+
ServiceContext,
|
|
93
|
+
credential_of,
|
|
94
|
+
iter_file,
|
|
95
|
+
owner_scope,
|
|
96
|
+
require_capability,
|
|
97
|
+
require_job,
|
|
98
|
+
resolve_managed_audio,
|
|
99
|
+
)
|
|
100
|
+
from .schemas import (
|
|
101
|
+
BudgetOut,
|
|
102
|
+
CapabilitiesOut,
|
|
103
|
+
CreateJobRequest,
|
|
104
|
+
EstimateOut,
|
|
105
|
+
EstimateRequest,
|
|
106
|
+
GenerationStateOut,
|
|
107
|
+
IntegrityOut,
|
|
108
|
+
JobError,
|
|
109
|
+
JobOut,
|
|
110
|
+
JobPage,
|
|
111
|
+
JobProgress,
|
|
112
|
+
JobTextOut,
|
|
113
|
+
LicenseOut,
|
|
114
|
+
MinimumBudgetOut,
|
|
115
|
+
ModelOut,
|
|
116
|
+
ModelsOut,
|
|
117
|
+
ResourcePolicyOut,
|
|
118
|
+
ResultOut,
|
|
119
|
+
SegmentOut,
|
|
120
|
+
SegmentsOut,
|
|
121
|
+
StatusOut,
|
|
122
|
+
VoiceChoice,
|
|
123
|
+
VoiceOut,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
#: What a caller is told to wait for something that is merely not finished
|
|
127
|
+
#: yet. ``echoact.policy`` fixes no number for it, so the job engine's own
|
|
128
|
+
#: busy hint is reused rather than a second one invented: both answer "the one
|
|
129
|
+
#: generation slot is working, come back shortly".
|
|
130
|
+
NOT_READY_RETRY_AFTER_S = BUSY_RETRY_AFTER_S
|
|
131
|
+
|
|
132
|
+
AUDIO_MEDIA_TYPE = "audio/wav"
|
|
133
|
+
|
|
134
|
+
#: N-19 and F-60: a result belongs to one owner, so no shared intermediary may
|
|
135
|
+
#: keep a copy and no browser may replay one from its cache.
|
|
136
|
+
_AUDIO_HEADERS = {"Cache-Control": "private, no-store", "Accept-Ranges": "none"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_router(context: ServiceContext) -> APIRouter:
|
|
140
|
+
"""Register the twelve operations against one service context."""
|
|
141
|
+
router = APIRouter()
|
|
142
|
+
|
|
143
|
+
# ==================================================================
|
|
144
|
+
# F-53 -- service and capability query
|
|
145
|
+
# ==================================================================
|
|
146
|
+
|
|
147
|
+
@router.get(
|
|
148
|
+
"/status",
|
|
149
|
+
response_model=StatusOut,
|
|
150
|
+
summary="Service, version, supported capabilities, resource policy",
|
|
151
|
+
)
|
|
152
|
+
def get_status(request: Request) -> StatusOut:
|
|
153
|
+
"""F-53's uptime, version, capabilities, and *actual* resource policy.
|
|
154
|
+
|
|
155
|
+
The budget reported is the one a job would run under right now, which
|
|
156
|
+
F-21 and N-04 make a function of the machine at this instant rather
|
|
157
|
+
than of the owner's setting alone. A machine that cannot currently
|
|
158
|
+
grant F-23's floor is reported as such instead of failing the query:
|
|
159
|
+
a caller asking whether the service is usable is exactly the caller
|
|
160
|
+
that needs to be told it is not.
|
|
161
|
+
"""
|
|
162
|
+
credential = credential_of(request)
|
|
163
|
+
settings = context.settings
|
|
164
|
+
budget, budget_reason = _current_budget(context)
|
|
165
|
+
engine = context.engine
|
|
166
|
+
current = engine.current()
|
|
167
|
+
mine = bool(
|
|
168
|
+
current is not None
|
|
169
|
+
and (credential.is_owner or current.owner_client_id == credential.client_id)
|
|
170
|
+
)
|
|
171
|
+
granted = sorted(c.value for c in credential.effective_capabilities)
|
|
172
|
+
return StatusOut(
|
|
173
|
+
service="echoact",
|
|
174
|
+
version=_app_version(),
|
|
175
|
+
api_version=API_VERSION,
|
|
176
|
+
state="draining" if context.draining else "running",
|
|
177
|
+
started_at=context.started_at,
|
|
178
|
+
uptime_s=max(0.0, ids.now() - context.started_at),
|
|
179
|
+
bind_host=context.host,
|
|
180
|
+
bind_port=context.port,
|
|
181
|
+
mcp_enabled=bool(settings.mcp_enabled),
|
|
182
|
+
client_id=credential.client_id,
|
|
183
|
+
client_name=credential.name,
|
|
184
|
+
credential_expires_at=credential.expires_at,
|
|
185
|
+
capabilities=CapabilitiesOut(
|
|
186
|
+
granted=granted,
|
|
187
|
+
job_kinds=list(JobKind),
|
|
188
|
+
languages=list(Language),
|
|
189
|
+
styles=list(SpeakingStyle),
|
|
190
|
+
input_formats=list(SUPPORTED_FORMATS),
|
|
191
|
+
bounded_wait=True,
|
|
192
|
+
estimate=True,
|
|
193
|
+
upload=True,
|
|
194
|
+
history=credential.has_capability(Capability.READ_HISTORY),
|
|
195
|
+
results=credential.has_capability(Capability.READ_RESULTS),
|
|
196
|
+
generation=credential.has_capability(Capability.GENERATE),
|
|
197
|
+
),
|
|
198
|
+
resource_policy=ResourcePolicyOut(
|
|
199
|
+
max_input_codepoints=MAX_INPUT_CODEPOINTS,
|
|
200
|
+
max_upload_bytes=MAX_IMPORT_FILE_BYTES,
|
|
201
|
+
max_request_body_bytes=MAX_REQUEST_BODY_BYTES,
|
|
202
|
+
concurrent_generation=MAX_CONCURRENT_GENERATION,
|
|
203
|
+
generation_requests_per_minute=RATE_GENERATION_PER_MIN,
|
|
204
|
+
other_requests_per_minute=RATE_OTHER_PER_MIN,
|
|
205
|
+
list_page_default=LIST_PAGE_DEFAULT,
|
|
206
|
+
list_page_max=LIST_PAGE_MAX,
|
|
207
|
+
bounded_wait_default_s=BOUNDED_WAIT_DEFAULT_S,
|
|
208
|
+
bounded_wait_max_s=context.bounded_wait_ceiling_s,
|
|
209
|
+
one_off_result_ttl_s=ONEOFF_RESULT_TTL_S,
|
|
210
|
+
idempotency_ttl_s=IDEMPOTENCY_TTL_S,
|
|
211
|
+
tempo_min=TEMPO_MIN,
|
|
212
|
+
tempo_max=TEMPO_MAX,
|
|
213
|
+
applied_budget=_budget_out(budget),
|
|
214
|
+
budget_unavailable_reason=budget_reason,
|
|
215
|
+
),
|
|
216
|
+
generation=GenerationStateOut(
|
|
217
|
+
busy=bool(engine.busy),
|
|
218
|
+
current_job_id=current.job_id if (current is not None and mine) else None,
|
|
219
|
+
current_job_is_mine=mine,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# ==================================================================
|
|
224
|
+
# F-53 -- models and voice/setting choices
|
|
225
|
+
# ==================================================================
|
|
226
|
+
|
|
227
|
+
@router.get(
|
|
228
|
+
"/models",
|
|
229
|
+
response_model=ModelsOut,
|
|
230
|
+
summary="Models and voice/setting choices",
|
|
231
|
+
)
|
|
232
|
+
def get_models(request: Request) -> ModelsOut:
|
|
233
|
+
"""F-53's model list, with every voice described.
|
|
234
|
+
|
|
235
|
+
The description is not decoration: F-53 says a bare identifier gives a
|
|
236
|
+
caller that is not a person no basis for choosing a voice, so the
|
|
237
|
+
manifest's own text travels here and to the GUI alike.
|
|
238
|
+
|
|
239
|
+
A model that is not downloaded reports ``state`` and a reason and is
|
|
240
|
+
still listed. F-04 forbids hiding it or substituting another, and
|
|
241
|
+
F-53 requires an undownloaded model to be distinguishable from a
|
|
242
|
+
server fault -- which it is, because this answers 200 either way and
|
|
243
|
+
says which.
|
|
244
|
+
"""
|
|
245
|
+
credential_of(request)
|
|
246
|
+
budget, _ = _current_budget(context)
|
|
247
|
+
statuses = context.registry.statuses(budget)
|
|
248
|
+
manifest = context.manifest
|
|
249
|
+
models: list[ModelOut] = []
|
|
250
|
+
for status in statuses:
|
|
251
|
+
entry = manifest.get(status.model_id)
|
|
252
|
+
models.append(
|
|
253
|
+
ModelOut(
|
|
254
|
+
model_id=status.model_id,
|
|
255
|
+
display_name=status.display_name,
|
|
256
|
+
state=status.state.value,
|
|
257
|
+
ready=status.state is ModelState.READY,
|
|
258
|
+
runnable=status.runnable,
|
|
259
|
+
unavailable_reason=status.unavailable_reason,
|
|
260
|
+
sample_rate=status.sample_rate,
|
|
261
|
+
languages=[Language(code) for code in status.languages],
|
|
262
|
+
styles=list(SpeakingStyle),
|
|
263
|
+
voices=[
|
|
264
|
+
VoiceChoice(
|
|
265
|
+
voice_id=voice.voice_id,
|
|
266
|
+
display_name=voice.display_name,
|
|
267
|
+
gender=voice.gender,
|
|
268
|
+
description=voice.description,
|
|
269
|
+
)
|
|
270
|
+
for voice in entry.voices
|
|
271
|
+
],
|
|
272
|
+
minimum_budget=MinimumBudgetOut(
|
|
273
|
+
memory_bytes=status.minimum_memory_bytes,
|
|
274
|
+
cpu_percent=status.minimum_cpu_percent,
|
|
275
|
+
),
|
|
276
|
+
bytes_total=status.bytes_total,
|
|
277
|
+
bytes_present=status.bytes_present,
|
|
278
|
+
license=LicenseOut(
|
|
279
|
+
name=status.license_name,
|
|
280
|
+
acceptance_required=status.license_acceptance_required,
|
|
281
|
+
accepted=status.license_accepted,
|
|
282
|
+
),
|
|
283
|
+
download_authorised=status.download_authorised,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return ModelsOut(
|
|
287
|
+
models=models,
|
|
288
|
+
languages=list(Language),
|
|
289
|
+
styles=list(SpeakingStyle),
|
|
290
|
+
genders=list(Gender),
|
|
291
|
+
input_formats=list(SUPPORTED_FORMATS),
|
|
292
|
+
encodings=list(SELECTABLE_ENCODINGS),
|
|
293
|
+
tempo_min=TEMPO_MIN,
|
|
294
|
+
tempo_max=TEMPO_MAX,
|
|
295
|
+
tempo_default=TEMPO_DEFAULT,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# ==================================================================
|
|
299
|
+
# F-88 -- pre-flight estimate
|
|
300
|
+
# ==================================================================
|
|
301
|
+
|
|
302
|
+
@router.post(
|
|
303
|
+
"/estimate",
|
|
304
|
+
response_model=EstimateOut,
|
|
305
|
+
summary="Validate text and settings and estimate the work, without creating a job",
|
|
306
|
+
)
|
|
307
|
+
def post_estimate(request: Request, body: EstimateRequest) -> EstimateOut:
|
|
308
|
+
"""F-88's pre-flight. Creates no job, loads no model, synthesises nothing.
|
|
309
|
+
|
|
310
|
+
An invalid request still answers 200 with ``valid: false`` and every
|
|
311
|
+
problem it found. F-88 says an estimate returns "validation results",
|
|
312
|
+
and a caller asking "would this work?" is best served by all the
|
|
313
|
+
reasons it would not rather than by the first exception -- which is
|
|
314
|
+
also why the model's readiness is reported here instead of being a
|
|
315
|
+
refusal.
|
|
316
|
+
"""
|
|
317
|
+
require_capability(context, request, Capability.GENERATE)
|
|
318
|
+
settings = _voice_settings(body.voice)
|
|
319
|
+
report = estimate(
|
|
320
|
+
body.text,
|
|
321
|
+
settings,
|
|
322
|
+
context.manifest,
|
|
323
|
+
slot_free=not context.engine.busy,
|
|
324
|
+
model_ready=_model_ready(context, settings.model_id),
|
|
325
|
+
)
|
|
326
|
+
return EstimateOut(**report.to_dict())
|
|
327
|
+
|
|
328
|
+
# ==================================================================
|
|
329
|
+
# F-54 -- create a job
|
|
330
|
+
# ==================================================================
|
|
331
|
+
|
|
332
|
+
@router.post(
|
|
333
|
+
"/jobs",
|
|
334
|
+
response_model=JobOut,
|
|
335
|
+
status_code=202,
|
|
336
|
+
summary="Create a job from text or an upload, with a required duplicate-prevention key",
|
|
337
|
+
responses={
|
|
338
|
+
200: {
|
|
339
|
+
"model": JobOut,
|
|
340
|
+
"description": (
|
|
341
|
+
"An existing job, returned unchanged for a repeated duplicate-prevention "
|
|
342
|
+
"key (F-49), or a job that reached a terminal state inside the requested "
|
|
343
|
+
"wait (F-88)."
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
202: {"description": "Accepted. The job continues; poll or fetch the result."},
|
|
347
|
+
},
|
|
348
|
+
openapi_extra={"requestBody": _create_job_request_body()},
|
|
349
|
+
)
|
|
350
|
+
async def post_jobs(request: Request, response: Response) -> JobOut:
|
|
351
|
+
"""F-54's generation request, with F-49's key and F-88's optional wait.
|
|
352
|
+
|
|
353
|
+
Async only because the body may be a multipart upload and reading it
|
|
354
|
+
is the one part that belongs on the event loop; everything that can
|
|
355
|
+
block -- validation, the store, the engine -- is handed to a worker
|
|
356
|
+
thread, so a request that opts into a ten-second wait does not stall
|
|
357
|
+
the nine other things N-22 promises to answer within one second.
|
|
358
|
+
"""
|
|
359
|
+
body, upload, filename = await _read_create_request(request)
|
|
360
|
+
return await run_in_threadpool(_create_job, context, request, response, body, upload, filename)
|
|
361
|
+
|
|
362
|
+
# ==================================================================
|
|
363
|
+
# F-56 -- history
|
|
364
|
+
# ==================================================================
|
|
365
|
+
|
|
366
|
+
@router.get(
|
|
367
|
+
"/jobs",
|
|
368
|
+
response_model=JobPage,
|
|
369
|
+
summary="Authorised retained history query",
|
|
370
|
+
)
|
|
371
|
+
def get_jobs(
|
|
372
|
+
request: Request,
|
|
373
|
+
limit: int = Query(
|
|
374
|
+
LIST_PAGE_DEFAULT,
|
|
375
|
+
description=(
|
|
376
|
+
f"Items per page. Clamped to 1..{LIST_PAGE_MAX} rather than refused, "
|
|
377
|
+
"because 4.1 fixes the page size regardless of what is asked."
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
offset: int = Query(0, description="Rows to skip, oldest-last ordering by creation time."),
|
|
381
|
+
state: JobState | None = Query(None),
|
|
382
|
+
model_id: str | None = Query(None),
|
|
383
|
+
retention: RetentionMode | None = Query(
|
|
384
|
+
None,
|
|
385
|
+
description=(
|
|
386
|
+
"Defaults to 'retained': F-56 authorises listing retained jobs. "
|
|
387
|
+
"Pass 'one_off' to list one-off jobs still inside their lifetime."
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
) -> JobPage:
|
|
391
|
+
"""F-56's list: summaries only, scoped to the caller's own jobs.
|
|
392
|
+
|
|
393
|
+
The body text is absent by design -- F-56 makes the summary the
|
|
394
|
+
default and puts the snapshot behind its own request -- and so is
|
|
395
|
+
every other owner's row, unless the credential is the GUI owner's,
|
|
396
|
+
which F-50 allows to review all jobs.
|
|
397
|
+
"""
|
|
398
|
+
credential = require_capability(context, request, Capability.READ_HISTORY)
|
|
399
|
+
page = context.store.list_jobs(
|
|
400
|
+
owner_client_id=owner_scope(credential),
|
|
401
|
+
retention=retention or RetentionMode.RETAINED,
|
|
402
|
+
states=[state] if state is not None else None,
|
|
403
|
+
model_id=model_id,
|
|
404
|
+
limit=limit,
|
|
405
|
+
offset=max(0, offset),
|
|
406
|
+
include_source_text=False,
|
|
407
|
+
)
|
|
408
|
+
return JobPage(
|
|
409
|
+
items=[_summary_out(summary) for summary in page.items],
|
|
410
|
+
total=page.total,
|
|
411
|
+
limit=page.limit,
|
|
412
|
+
offset=page.offset,
|
|
413
|
+
has_more=page.has_more,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# ==================================================================
|
|
417
|
+
# F-54 -- one job's state
|
|
418
|
+
# ==================================================================
|
|
419
|
+
|
|
420
|
+
@router.get(
|
|
421
|
+
"/jobs/{job_id}",
|
|
422
|
+
response_model=JobOut,
|
|
423
|
+
summary="State, progress, and whether the result is ready",
|
|
424
|
+
)
|
|
425
|
+
def get_job(request: Request, job_id: str) -> JobOut:
|
|
426
|
+
"""F-48's status query. A job owned by another client is 404 (N-19)."""
|
|
427
|
+
_, job = require_job(context, request, job_id, Capability.GENERATE, include_segments=True)
|
|
428
|
+
return _job_out(context, job)
|
|
429
|
+
|
|
430
|
+
# ==================================================================
|
|
431
|
+
# F-56 -- the source-text snapshot
|
|
432
|
+
# ==================================================================
|
|
433
|
+
|
|
434
|
+
@router.get(
|
|
435
|
+
"/jobs/{job_id}/text",
|
|
436
|
+
response_model=JobTextOut,
|
|
437
|
+
summary="Source-text snapshot of a retained job, separately authorised",
|
|
438
|
+
)
|
|
439
|
+
def get_job_text(request: Request, job_id: str) -> JobTextOut:
|
|
440
|
+
"""F-56's separate, separately authorised snapshot request.
|
|
441
|
+
|
|
442
|
+
A job whose snapshot is gone answers 404 rather than an empty string.
|
|
443
|
+
4.2 lets a one-off job's text be cleared once its window closes, and
|
|
444
|
+
reporting that as "the text is ''" would be a different fact.
|
|
445
|
+
"""
|
|
446
|
+
_, job = require_job(context, request, job_id, Capability.READ_HISTORY)
|
|
447
|
+
text = context.store.get_job_text(job_id)
|
|
448
|
+
if text is None:
|
|
449
|
+
raise EchoActError(
|
|
450
|
+
Code.NOT_FOUND,
|
|
451
|
+
"This job no longer holds a source-text snapshot.",
|
|
452
|
+
detail={"job_id": job_id, "retention": job.retention.value},
|
|
453
|
+
)
|
|
454
|
+
return JobTextOut(
|
|
455
|
+
job_id=job_id, text=text, codepoints=len(text), retention=job.retention
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# ==================================================================
|
|
459
|
+
# F-49 -- cancellation
|
|
460
|
+
# ==================================================================
|
|
461
|
+
|
|
462
|
+
@router.post(
|
|
463
|
+
"/jobs/{job_id}/cancel",
|
|
464
|
+
response_model=JobOut,
|
|
465
|
+
status_code=202,
|
|
466
|
+
summary="Cancel a job; an already-terminated job answers 200 with its final state",
|
|
467
|
+
responses={
|
|
468
|
+
200: {
|
|
469
|
+
"model": JobOut,
|
|
470
|
+
"description": "The job had already reached a terminal state; it is returned.",
|
|
471
|
+
},
|
|
472
|
+
202: {"description": "The cancellation was accepted; the slot is released shortly."},
|
|
473
|
+
},
|
|
474
|
+
)
|
|
475
|
+
def post_cancel(request: Request, response: Response, job_id: str) -> JobOut:
|
|
476
|
+
"""F-49's cancellation, and N-22's two different deadlines.
|
|
477
|
+
|
|
478
|
+
Cancelling has to be *accepted* within a second at p95, while the
|
|
479
|
+
generation slot has five seconds to be released. The kill therefore
|
|
480
|
+
runs on the service's own thread pool and this handler answers as soon
|
|
481
|
+
as it knows the cancellation was taken, waiting only a quarter second
|
|
482
|
+
in case the job stops instantly and a truer state can be reported.
|
|
483
|
+
|
|
484
|
+
Repeating it adds no further side effects, which F-49 requires: an
|
|
485
|
+
already-terminal job is answered 200 with the state it reached, and
|
|
486
|
+
nothing is asked of the engine at all.
|
|
487
|
+
"""
|
|
488
|
+
_, job = require_job(context, request, job_id, Capability.GENERATE)
|
|
489
|
+
if job.state.is_terminal:
|
|
490
|
+
response.status_code = 200
|
|
491
|
+
return _job_out(context, _reload(context, job_id))
|
|
492
|
+
|
|
493
|
+
future = context.executor.submit(context.engine.cancel, job_id)
|
|
494
|
+
try:
|
|
495
|
+
future.result(CANCEL_ACK_GRACE_S)
|
|
496
|
+
except TimeoutError:
|
|
497
|
+
# The expected case for a job that is mid-segment: the worker has
|
|
498
|
+
# up to five seconds to die and the caller is not made to wait for
|
|
499
|
+
# it. The kill goes on without this request.
|
|
500
|
+
pass
|
|
501
|
+
response.status_code = 202
|
|
502
|
+
return _job_out(context, _reload(context, job_id))
|
|
503
|
+
|
|
504
|
+
# ==================================================================
|
|
505
|
+
# F-55 -- segments
|
|
506
|
+
# ==================================================================
|
|
507
|
+
|
|
508
|
+
@router.get(
|
|
509
|
+
"/jobs/{job_id}/segments",
|
|
510
|
+
response_model=SegmentsOut,
|
|
511
|
+
summary="Ready segments and the last sequence number",
|
|
512
|
+
)
|
|
513
|
+
def get_segments(request: Request, job_id: str) -> SegmentsOut:
|
|
514
|
+
"""F-55's mapping for the segments that exist.
|
|
515
|
+
|
|
516
|
+
Only ready segments are listed. F-55 forbids returning an ungenerated
|
|
517
|
+
segment as if it were a finished result, and the honest way to say
|
|
518
|
+
"there are more coming" is ``complete: false`` beside a count, not a
|
|
519
|
+
row with null times in it.
|
|
520
|
+
"""
|
|
521
|
+
_, job = require_job(context, request, job_id, Capability.READ_RESULTS)
|
|
522
|
+
ready = context.store.list_segments(job_id, ready_only=True)
|
|
523
|
+
return SegmentsOut(
|
|
524
|
+
job_id=job_id,
|
|
525
|
+
segments=[_segment_out(segment) for segment in ready],
|
|
526
|
+
last_sequence=ready[-1].index if ready else None,
|
|
527
|
+
ready_count=len(ready),
|
|
528
|
+
total_segments=job.total_segments,
|
|
529
|
+
complete=job.state is JobState.COMPLETE,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
@router.get(
|
|
533
|
+
"/jobs/{job_id}/segments/{segment_id}/audio",
|
|
534
|
+
summary="Audio of one ready segment",
|
|
535
|
+
response_class=StreamingResponse,
|
|
536
|
+
responses={200: {"content": {AUDIO_MEDIA_TYPE: {}}, "description": "The segment's WAV."}},
|
|
537
|
+
)
|
|
538
|
+
def get_segment_audio(request: Request, job_id: str, segment_id: str) -> StreamingResponse:
|
|
539
|
+
"""One ready segment's WAV, streamed.
|
|
540
|
+
|
|
541
|
+
A segment that exists but carries no audio -- a range of characters
|
|
542
|
+
F-27 keeps on the timeline without sending it for synthesis -- is
|
|
543
|
+
reported as gone rather than as not yet ready. "Not ready" invites a
|
|
544
|
+
retry, and this one will never become ready. Neither will one whose
|
|
545
|
+
job has already ended, which is why the refusal below asks the job
|
|
546
|
+
and not only the segment.
|
|
547
|
+
"""
|
|
548
|
+
_, job = require_job(context, request, job_id, Capability.READ_RESULTS)
|
|
549
|
+
segment = _find_segment(context, job_id, segment_id)
|
|
550
|
+
if not segment.ready:
|
|
551
|
+
raise _not_ready(
|
|
552
|
+
job,
|
|
553
|
+
Code.SEGMENT_NOT_READY,
|
|
554
|
+
{"job_id": job_id, "segment_id": segment_id, "index": segment.index},
|
|
555
|
+
ended="That segment was never generated; the job ended first.",
|
|
556
|
+
)
|
|
557
|
+
path = resolve_managed_audio(context, segment.audio_path)
|
|
558
|
+
if path is None:
|
|
559
|
+
raise EchoActError(
|
|
560
|
+
Code.RESULT_MISSING,
|
|
561
|
+
"That segment carries no audio of its own.",
|
|
562
|
+
detail={
|
|
563
|
+
"job_id": job_id,
|
|
564
|
+
"segment_id": segment_id,
|
|
565
|
+
"spoken": segment.is_spoken,
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
return _stream(path, f"{job_id}-{segment.index:05d}.wav")
|
|
569
|
+
|
|
570
|
+
# ==================================================================
|
|
571
|
+
# F-55 -- the full result
|
|
572
|
+
# ==================================================================
|
|
573
|
+
|
|
574
|
+
@router.get(
|
|
575
|
+
"/jobs/{job_id}/audio",
|
|
576
|
+
summary="Completed full audio",
|
|
577
|
+
response_class=StreamingResponse,
|
|
578
|
+
responses={200: {"content": {AUDIO_MEDIA_TYPE: {}}, "description": "The job's WAV."}},
|
|
579
|
+
)
|
|
580
|
+
def get_audio(request: Request, job_id: str) -> StreamingResponse:
|
|
581
|
+
"""F-55's completed WAV, in F-82's format, streamed rather than loaded.
|
|
582
|
+
|
|
583
|
+
An incomplete job answers 409 and never a partial file: F-55 forbids
|
|
584
|
+
handing back an incomplete final file as though it were finished, and
|
|
585
|
+
F-16's partial export is a GUI operation the user performs knowingly,
|
|
586
|
+
not something a client gets by asking early.
|
|
587
|
+
"""
|
|
588
|
+
_, job = require_job(context, request, job_id, Capability.READ_RESULTS)
|
|
589
|
+
result = _require_result(job)
|
|
590
|
+
if job.state is not JobState.COMPLETE:
|
|
591
|
+
raise _not_ready(
|
|
592
|
+
job,
|
|
593
|
+
Code.RESULT_NOT_READY,
|
|
594
|
+
{"job_id": job_id},
|
|
595
|
+
ended="This job ended before its full audio was assembled.",
|
|
596
|
+
)
|
|
597
|
+
if _expired(result):
|
|
598
|
+
raise EchoActError(
|
|
599
|
+
Code.RESULT_EXPIRED, detail={"job_id": job_id, "expires_at": result.expires_at}
|
|
600
|
+
)
|
|
601
|
+
path = resolve_managed_audio(context, result.relative_path)
|
|
602
|
+
if path is None:
|
|
603
|
+
raise EchoActError(Code.RESULT_MISSING, detail={"job_id": job_id})
|
|
604
|
+
return _stream(path, f"{job_id}.wav")
|
|
605
|
+
|
|
606
|
+
@router.get(
|
|
607
|
+
"/jobs/{job_id}/result",
|
|
608
|
+
response_model=ResultOut,
|
|
609
|
+
summary="Result metadata: format, rate, length, size, integrity value, expiry",
|
|
610
|
+
)
|
|
611
|
+
def get_result(request: Request, job_id: str) -> ResultOut:
|
|
612
|
+
"""4.2's Result entity.
|
|
613
|
+
|
|
614
|
+
``expired`` and ``available`` are separate answers. 4.2 keeps a
|
|
615
|
+
result row after its lifetime passes so that a repeated re-request key
|
|
616
|
+
can return the existing job and the result's expired state, and 5.3
|
|
617
|
+
wants the reason retrieval fails rather than a bare absence.
|
|
618
|
+
"""
|
|
619
|
+
_, job = require_job(context, request, job_id, Capability.READ_RESULTS)
|
|
620
|
+
result = _require_result(job)
|
|
621
|
+
path = resolve_managed_audio(context, result.relative_path)
|
|
622
|
+
expired = _expired(result)
|
|
623
|
+
return ResultOut(
|
|
624
|
+
job_id=job_id,
|
|
625
|
+
result_id=result.result_id,
|
|
626
|
+
format=wav.FORMAT_NAME,
|
|
627
|
+
media_type=AUDIO_MEDIA_TYPE,
|
|
628
|
+
sample_rate=result.sample_rate,
|
|
629
|
+
channels=result.channels,
|
|
630
|
+
sample_width_bits=result.sample_width_bits,
|
|
631
|
+
frame_count=result.frame_count,
|
|
632
|
+
duration_ms=result.duration_ms,
|
|
633
|
+
byte_size=result.byte_size,
|
|
634
|
+
integrity=IntegrityOut(
|
|
635
|
+
algorithm="sha256",
|
|
636
|
+
value=result.digest,
|
|
637
|
+
state=context.store.result_integrity(result.result_id).value,
|
|
638
|
+
),
|
|
639
|
+
created_at=result.created_at,
|
|
640
|
+
expires_at=result.expires_at,
|
|
641
|
+
expired=expired,
|
|
642
|
+
available=bool(path is not None and not expired),
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
return router
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ======================================================================
|
|
649
|
+
# Job creation, off the event loop
|
|
650
|
+
# ======================================================================
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
async def _read_create_request(
|
|
654
|
+
request: Request,
|
|
655
|
+
) -> tuple[CreateJobRequest, bytes | None, str | None]:
|
|
656
|
+
"""Read F-54's two alternative body shapes.
|
|
657
|
+
|
|
658
|
+
JSON carries the text inline; multipart carries the same object in a
|
|
659
|
+
``request`` part beside the file. One model validates both, so the upload
|
|
660
|
+
path cannot drift into accepting something the inline path refuses -- F-37
|
|
661
|
+
requires every entry path to apply the same rules.
|
|
662
|
+
"""
|
|
663
|
+
content_type = request.headers.get("content-type", "")
|
|
664
|
+
media_type = content_type.split(";", 1)[0].strip().lower()
|
|
665
|
+
|
|
666
|
+
if media_type == "application/json" or media_type == "":
|
|
667
|
+
raw = await request.body()
|
|
668
|
+
return _validate_create(raw or b"{}"), None, None
|
|
669
|
+
|
|
670
|
+
if media_type in ("multipart/form-data", "application/x-www-form-urlencoded"):
|
|
671
|
+
form = await _read_form(request)
|
|
672
|
+
try:
|
|
673
|
+
described = form.get("request")
|
|
674
|
+
upload = form.get("file")
|
|
675
|
+
if described is None or not isinstance(described, str):
|
|
676
|
+
raise EchoActError(
|
|
677
|
+
Code.JOB_KIND_MISSING,
|
|
678
|
+
"An upload must carry a 'request' part describing the job.",
|
|
679
|
+
detail={"field": "request"},
|
|
680
|
+
)
|
|
681
|
+
data: bytes | None = None
|
|
682
|
+
filename: str | None = None
|
|
683
|
+
if upload is not None and not isinstance(upload, str):
|
|
684
|
+
data = await upload.read()
|
|
685
|
+
filename = upload.filename
|
|
686
|
+
return _validate_create(described.encode("utf-8")), data, filename
|
|
687
|
+
finally:
|
|
688
|
+
await form.close()
|
|
689
|
+
|
|
690
|
+
raise EchoActError(
|
|
691
|
+
Code.FILE_UNSUPPORTED,
|
|
692
|
+
"The request body must be JSON or a multipart upload.",
|
|
693
|
+
detail={"content_type": media_type},
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
async def _read_form(request: Request) -> FormData:
|
|
698
|
+
"""Parse a form body, with Section 2.10's status for one that will not parse.
|
|
699
|
+
|
|
700
|
+
Reading the form here rather than through a declared parameter is what
|
|
701
|
+
lets one model validate both body shapes, but it also steps around the
|
|
702
|
+
400 FastAPI wraps its own form handling in. A body whose multipart
|
|
703
|
+
framing is broken, whose boundary is missing, or which carries more parts
|
|
704
|
+
than the parser will assemble raises out of the parser as neither an
|
|
705
|
+
``EchoActError`` nor a validation error, so without this it reaches the
|
|
706
|
+
unexpected-exception handler: 500 INTERNAL, a status Section 2.10 does
|
|
707
|
+
not list for a malformed request, in an envelope carrying no code a
|
|
708
|
+
client can act on. Starlette's refusal takes two shapes -- it wraps the
|
|
709
|
+
parser's exception in a bare 400 when the request carries an app in its
|
|
710
|
+
scope and re-raises the exception itself otherwise -- and the bare 400 is
|
|
711
|
+
no better here: it reaches ``http_exception_error`` as a status with no
|
|
712
|
+
code behind it and becomes the same 500. Both are caught.
|
|
713
|
+
|
|
714
|
+
``max_part_size`` is 4.1's request cap rather than Starlette's 1 MiB
|
|
715
|
+
default, so the only size limit on a body is the one the gate has already
|
|
716
|
+
enforced -- before a byte was read, from ``Content-Length``, or as the
|
|
717
|
+
chunks arrived. Left at the default, a part well inside 4.1's 2,000,000
|
|
718
|
+
bytes is refused by a limit no part of this contract states.
|
|
719
|
+
"""
|
|
720
|
+
try:
|
|
721
|
+
return await request.form(max_part_size=MAX_REQUEST_BODY_BYTES)
|
|
722
|
+
except StarletteHTTPException as exc:
|
|
723
|
+
if exc.status_code != 400:
|
|
724
|
+
raise
|
|
725
|
+
raise _unreadable_form(exc) from exc
|
|
726
|
+
except (MultiPartException, FormParserError) as exc:
|
|
727
|
+
raise _unreadable_form(exc) from exc
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _unreadable_form(cause: Exception) -> EchoActError:
|
|
731
|
+
"""A body that will not parse, as Section 2.10's 400 with a code on it.
|
|
732
|
+
|
|
733
|
+
The code is the same one an unparseable JSON body already answers with:
|
|
734
|
+
a body nothing could be read out of has, among other things, stated no
|
|
735
|
+
job kind. The parser's own text is dropped rather than passed on --
|
|
736
|
+
it quotes the boundary and the byte offset it stopped at, which is a
|
|
737
|
+
fragment of the caller's body, and F-57 keeps that out of a response
|
|
738
|
+
just as N-20 keeps it out of a log.
|
|
739
|
+
"""
|
|
740
|
+
return EchoActError(
|
|
741
|
+
Code.MALFORMED_REQUEST,
|
|
742
|
+
"The request body could not be read as a form.",
|
|
743
|
+
detail={"field": "body"},
|
|
744
|
+
cause=cause,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _validate_create(raw: bytes) -> CreateJobRequest:
|
|
749
|
+
try:
|
|
750
|
+
return CreateJobRequest.model_validate_json(raw)
|
|
751
|
+
except ValidationError as exc:
|
|
752
|
+
# Re-raised as FastAPI's own type so that the single handler in
|
|
753
|
+
# ``service.errors`` decides the code and status; a second translation
|
|
754
|
+
# here is a second place for the contract to drift.
|
|
755
|
+
raise RequestValidationError(exc.errors()) from exc
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _create_job(
|
|
759
|
+
context: ServiceContext,
|
|
760
|
+
request: Request,
|
|
761
|
+
response: Response,
|
|
762
|
+
body: CreateJobRequest,
|
|
763
|
+
upload: bytes | None,
|
|
764
|
+
filename: str | None,
|
|
765
|
+
) -> JobOut:
|
|
766
|
+
"""F-54 and F-49, on a worker thread.
|
|
767
|
+
|
|
768
|
+
The bounded wait is the last thing that happens and changes only when the
|
|
769
|
+
answer is sent. F-88 is explicit that waiting is an optimisation and
|
|
770
|
+
never a different lifecycle, so the job is identical whether the caller
|
|
771
|
+
waited, the bound passed, or the caller never asked.
|
|
772
|
+
"""
|
|
773
|
+
credential = require_capability(context, request, Capability.GENERATE)
|
|
774
|
+
text = _resolve_input(body, upload, filename)
|
|
775
|
+
settings = _voice_settings(body.voice)
|
|
776
|
+
|
|
777
|
+
wait_s = clamp_wait(body.wait_s, ceiling_s=context.bounded_wait_ceiling_s)
|
|
778
|
+
# F-88: never wait while a model is being prepared or downloaded. Asked
|
|
779
|
+
# before submitting, because once the run thread starts the answer becomes
|
|
780
|
+
# a race with it rather than a property of this request.
|
|
781
|
+
would_load = context.engine.would_need_load(settings) if wait_s > 0 else False
|
|
782
|
+
|
|
783
|
+
job_request = JobRequest(
|
|
784
|
+
text=text,
|
|
785
|
+
settings=settings,
|
|
786
|
+
request_path=RequestPath.REST,
|
|
787
|
+
owner_client_id=credential.client_id,
|
|
788
|
+
idempotency_key=body.idempotency_key,
|
|
789
|
+
kind=body.kind,
|
|
790
|
+
retention=RetentionMode.RETAINED if body.retain else RetentionMode.ONE_OFF,
|
|
791
|
+
# F-70 names the client in a completion notice. The credential's own
|
|
792
|
+
# name is used rather than a label the caller supplies: N-18 keeps
|
|
793
|
+
# client-supplied text as data, and a self-chosen label in a
|
|
794
|
+
# notification is text the app would be repeating on trust.
|
|
795
|
+
client_label=credential.name,
|
|
796
|
+
wait_s=wait_s or None,
|
|
797
|
+
)
|
|
798
|
+
job, created = context.engine.submit(job_request)
|
|
799
|
+
|
|
800
|
+
waited_s = 0.0
|
|
801
|
+
if created and wait_s > 0 and not would_load:
|
|
802
|
+
started = ids.monotonic()
|
|
803
|
+
job = context.engine.wait(job.job_id, wait_s)
|
|
804
|
+
waited_s = round(ids.monotonic() - started, 3)
|
|
805
|
+
|
|
806
|
+
if not created:
|
|
807
|
+
# 4.2: the same key with the same content returns the existing job and
|
|
808
|
+
# its state, terminal or expired included, and regenerates nothing.
|
|
809
|
+
response.status_code = 200
|
|
810
|
+
elif job.state.is_terminal:
|
|
811
|
+
response.status_code = 200
|
|
812
|
+
else:
|
|
813
|
+
response.status_code = 202
|
|
814
|
+
return _job_out(context, job, waited_s=waited_s, duplicate=not created)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _resolve_input(
|
|
818
|
+
body: CreateJobRequest, upload: bytes | None, filename: str | None
|
|
819
|
+
) -> str:
|
|
820
|
+
"""F-54: inline text or an upload, never both and never neither.
|
|
821
|
+
|
|
822
|
+
An upload goes through ``text.loader.load_bytes``, which is the same
|
|
823
|
+
function the file dialog uses. F-37 requires an automated caller to reach
|
|
824
|
+
the identical checks in the identical order, and sharing the function is
|
|
825
|
+
the only way that stays true as the checks change.
|
|
826
|
+
"""
|
|
827
|
+
has_text = body.text is not None
|
|
828
|
+
has_upload = upload is not None
|
|
829
|
+
if has_text and has_upload:
|
|
830
|
+
raise EchoActError(Code.INPUT_AMBIGUOUS)
|
|
831
|
+
if has_upload:
|
|
832
|
+
loaded = load_bytes(
|
|
833
|
+
upload or b"",
|
|
834
|
+
filename=filename,
|
|
835
|
+
encoding=body.encoding,
|
|
836
|
+
confirm_unknown=body.confirm_unknown,
|
|
837
|
+
)
|
|
838
|
+
return loaded.text
|
|
839
|
+
if not has_text:
|
|
840
|
+
raise EchoActError(Code.INPUT_EMPTY, "Provide inline text or an upload.")
|
|
841
|
+
return body.text or ""
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# ======================================================================
|
|
845
|
+
# Projections
|
|
846
|
+
# ======================================================================
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _voice_settings(voice: Any) -> VoiceSettings:
|
|
850
|
+
return VoiceSettings(
|
|
851
|
+
model_id=voice.model_id,
|
|
852
|
+
language=voice.language,
|
|
853
|
+
gender=voice.gender,
|
|
854
|
+
voice_id=voice.voice_id,
|
|
855
|
+
style=voice.style,
|
|
856
|
+
tempo=voice.tempo,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _voice_out(settings: VoiceSettings) -> VoiceOut:
|
|
861
|
+
return VoiceOut(
|
|
862
|
+
model_id=settings.model_id,
|
|
863
|
+
voice_id=settings.voice_id,
|
|
864
|
+
gender=settings.gender,
|
|
865
|
+
language=settings.language,
|
|
866
|
+
style=settings.style,
|
|
867
|
+
tempo=settings.tempo,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _budget_out(budget: Budget | None) -> BudgetOut | None:
|
|
872
|
+
if budget is None:
|
|
873
|
+
return None
|
|
874
|
+
return BudgetOut(
|
|
875
|
+
cpu_percent=budget.cpu_percent,
|
|
876
|
+
memory_bytes=budget.memory_bytes,
|
|
877
|
+
intra_op_threads=budget.intra_op_threads,
|
|
878
|
+
inter_op_threads=budget.inter_op_threads,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _job_error(code: str | None, message: str | None) -> JobError | None:
|
|
883
|
+
"""5.3: a generation failure after acceptance is the job's state, not the
|
|
884
|
+
request's, so it is reported here with the same three facts F-57 puts in a
|
|
885
|
+
refusal -- code, message, and whether a retry can succeed."""
|
|
886
|
+
if not code:
|
|
887
|
+
return None
|
|
888
|
+
try:
|
|
889
|
+
parsed = Code(code)
|
|
890
|
+
except ValueError:
|
|
891
|
+
return JobError(code=code, message=message, retryable=False)
|
|
892
|
+
return JobError(code=parsed.value, message=message, retryable=is_retryable(parsed))
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _job_out(
|
|
896
|
+
context: ServiceContext,
|
|
897
|
+
job: Job,
|
|
898
|
+
*,
|
|
899
|
+
waited_s: float | None = None,
|
|
900
|
+
duplicate: bool | None = None,
|
|
901
|
+
) -> JobOut:
|
|
902
|
+
result = job.result
|
|
903
|
+
expired = _expired(result) if result is not None else False
|
|
904
|
+
duration_ms = result.duration_ms if result is not None else job.playable_ms()
|
|
905
|
+
return JobOut(
|
|
906
|
+
job_id=job.job_id,
|
|
907
|
+
kind=job.kind,
|
|
908
|
+
state=job.state,
|
|
909
|
+
terminal=job.state.is_terminal,
|
|
910
|
+
request_path=job.request_path,
|
|
911
|
+
owner_client_id=job.owner_client_id,
|
|
912
|
+
client_label=job.client_label,
|
|
913
|
+
retention=job.retention,
|
|
914
|
+
voice=_voice_out(job.settings),
|
|
915
|
+
budget=_budget_out(job.budget),
|
|
916
|
+
progress=JobProgress(
|
|
917
|
+
generated_segments=job.generated_segments,
|
|
918
|
+
total_segments=job.total_segments,
|
|
919
|
+
fraction=round(job.progress, 4),
|
|
920
|
+
),
|
|
921
|
+
created_at=job.created_at,
|
|
922
|
+
started_at=job.started_at,
|
|
923
|
+
ended_at=job.ended_at,
|
|
924
|
+
result_ready=bool(result is not None and not expired),
|
|
925
|
+
result_expired=expired,
|
|
926
|
+
audio_duration_ms=duration_ms,
|
|
927
|
+
error=_job_error(job.error_code, job.error_message),
|
|
928
|
+
waited_s=waited_s,
|
|
929
|
+
duplicate=duplicate,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _summary_out(summary: JobSummary) -> JobOut:
|
|
934
|
+
"""The same shape from F-56's summary row, which never reads a WAV."""
|
|
935
|
+
return JobOut(
|
|
936
|
+
job_id=summary.job_id,
|
|
937
|
+
kind=summary.kind,
|
|
938
|
+
state=summary.state,
|
|
939
|
+
terminal=summary.state.is_terminal,
|
|
940
|
+
request_path=summary.request_path,
|
|
941
|
+
owner_client_id=summary.owner_client_id,
|
|
942
|
+
client_label=summary.client_label,
|
|
943
|
+
retention=summary.retention,
|
|
944
|
+
voice=_voice_out(summary.settings),
|
|
945
|
+
budget=_budget_out(summary.budget),
|
|
946
|
+
progress=JobProgress(
|
|
947
|
+
generated_segments=summary.generated_segments,
|
|
948
|
+
total_segments=summary.total_segments,
|
|
949
|
+
fraction=round(
|
|
950
|
+
min(1.0, summary.generated_segments / summary.total_segments)
|
|
951
|
+
if summary.total_segments > 0
|
|
952
|
+
else 0.0,
|
|
953
|
+
4,
|
|
954
|
+
),
|
|
955
|
+
),
|
|
956
|
+
created_at=summary.created_at,
|
|
957
|
+
started_at=summary.started_at,
|
|
958
|
+
ended_at=summary.ended_at,
|
|
959
|
+
result_ready=bool(summary.has_result and not summary.result_expired),
|
|
960
|
+
result_expired=summary.result_expired,
|
|
961
|
+
audio_duration_ms=summary.audio_duration_ms,
|
|
962
|
+
error=_job_error(summary.error_code, summary.error_message),
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _segment_out(segment: Segment) -> SegmentOut:
|
|
967
|
+
span = segment.time
|
|
968
|
+
return SegmentOut(
|
|
969
|
+
segment_id=segment.segment_id,
|
|
970
|
+
index=segment.index,
|
|
971
|
+
source_start=segment.source.start,
|
|
972
|
+
source_end=segment.source.end,
|
|
973
|
+
start_ms=span.start_ms if span else 0,
|
|
974
|
+
end_ms=span.end_ms if span else 0,
|
|
975
|
+
duration_ms=span.duration_ms if span else 0,
|
|
976
|
+
language=segment.language,
|
|
977
|
+
spoken=segment.is_spoken,
|
|
978
|
+
has_audio=bool(segment.audio_path),
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
# ======================================================================
|
|
983
|
+
# Small helpers
|
|
984
|
+
# ======================================================================
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _app_version() -> str:
|
|
988
|
+
from .. import __version__
|
|
989
|
+
|
|
990
|
+
return __version__
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _current_budget(context: ServiceContext) -> tuple[Budget | None, str | None]:
|
|
994
|
+
"""The budget in force, or why there is none.
|
|
995
|
+
|
|
996
|
+
F-23 refuses to load a model under the floor, and ``resolve_budget_from_system``
|
|
997
|
+
says so by raising. A status query must not fail for that reason -- it is
|
|
998
|
+
precisely the query that should report it -- so the refusal becomes a
|
|
999
|
+
reason string with no path or host detail in it.
|
|
1000
|
+
"""
|
|
1001
|
+
try:
|
|
1002
|
+
return resolve_budget_from_system(context.settings), None
|
|
1003
|
+
except EchoActError as exc:
|
|
1004
|
+
return None, exc.message
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _model_ready(context: ServiceContext, model_id: str) -> bool:
|
|
1008
|
+
"""Shallow readiness, for F-88's estimate.
|
|
1009
|
+
|
|
1010
|
+
Shallow because N-22 gives an estimate one second at p95 and a deep verify
|
|
1011
|
+
hashes 385 MB. An unknown model is simply not ready; the estimate's own
|
|
1012
|
+
validation reports *why* it is unknown.
|
|
1013
|
+
"""
|
|
1014
|
+
if not context.manifest.has(model_id):
|
|
1015
|
+
return False
|
|
1016
|
+
try:
|
|
1017
|
+
return context.registry.status(model_id, deep=False).state is ModelState.READY
|
|
1018
|
+
except EchoActError:
|
|
1019
|
+
return False
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _reload(context: ServiceContext, job_id: str) -> Job:
|
|
1023
|
+
return context.store.get_job(job_id, include_source_text=False, include_segments=True)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _find_segment(context: ServiceContext, job_id: str, segment_id: str) -> Segment:
|
|
1027
|
+
for segment in context.store.list_segments(job_id):
|
|
1028
|
+
if segment.segment_id == segment_id:
|
|
1029
|
+
return segment
|
|
1030
|
+
raise EchoActError(
|
|
1031
|
+
Code.NOT_FOUND, "No such segment.", detail={"job_id": job_id, "segment_id": segment_id}
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _require_result(job: Job) -> Any:
|
|
1036
|
+
if job.result is None:
|
|
1037
|
+
raise _not_ready(
|
|
1038
|
+
job,
|
|
1039
|
+
Code.RESULT_NOT_READY,
|
|
1040
|
+
{"job_id": job.job_id},
|
|
1041
|
+
ended="This job ended without producing a result.",
|
|
1042
|
+
)
|
|
1043
|
+
return job.result
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _not_ready(job: Job, code: Code, detail: dict[str, Any], *, ended: str) -> EchoActError:
|
|
1047
|
+
""""Not finished yet" and "this will never finish" are different refusals.
|
|
1048
|
+
|
|
1049
|
+
Whether something is still coming is a fact about the *job*, not about
|
|
1050
|
+
the row being asked for, so the state decides which of the two this is.
|
|
1051
|
+
While the job can still get there, the answer is the retryable code with
|
|
1052
|
+
N-23's hint on it. Once the job has ended it cannot, and rule 8 forbids
|
|
1053
|
+
a permanent refusal from carrying a hint at all: a client that honours
|
|
1054
|
+
the hint -- which F-47 says it must -- would otherwise poll a failed job
|
|
1055
|
+
every three seconds for a result that can never appear. What it gets
|
|
1056
|
+
instead is 5.3's reason, the job's terminal state and its own failure
|
|
1057
|
+
code, in the same envelope the no-audio segment case already answers.
|
|
1058
|
+
"""
|
|
1059
|
+
described = {**detail, "state": job.state.value}
|
|
1060
|
+
if not job.state.is_terminal:
|
|
1061
|
+
return EchoActError(code, retry_after_s=NOT_READY_RETRY_AFTER_S, detail=described)
|
|
1062
|
+
if job.error_code:
|
|
1063
|
+
described["error_code"] = job.error_code
|
|
1064
|
+
return EchoActError(Code.RESULT_MISSING, ended, detail=described)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _expired(result: Any) -> bool:
|
|
1068
|
+
return result.expires_at is not None and result.expires_at <= ids.now()
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _stream(path: Path, download_name: str) -> StreamingResponse:
|
|
1072
|
+
"""Stream a managed WAV.
|
|
1073
|
+
|
|
1074
|
+
``Content-Length`` comes from the file on disk rather than from the stored
|
|
1075
|
+
row: the row is what the digest was taken over, and answering with a
|
|
1076
|
+
length the body will not match is worse than answering with the truth.
|
|
1077
|
+
The filename offered is built from identifiers the app minted, so nothing
|
|
1078
|
+
a caller supplied ever reaches a header.
|
|
1079
|
+
"""
|
|
1080
|
+
try:
|
|
1081
|
+
size = path.stat().st_size
|
|
1082
|
+
except OSError as exc:
|
|
1083
|
+
raise EchoActError(Code.RESULT_MISSING, cause=exc) from exc
|
|
1084
|
+
headers = {
|
|
1085
|
+
**_AUDIO_HEADERS,
|
|
1086
|
+
"Content-Length": str(size),
|
|
1087
|
+
"Content-Disposition": f'attachment; filename="{download_name}"',
|
|
1088
|
+
}
|
|
1089
|
+
return StreamingResponse(iter_file(path), media_type=AUDIO_MEDIA_TYPE, headers=headers)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _create_job_request_body() -> dict[str, Any]:
|
|
1093
|
+
"""The two alternative bodies F-54 allows, for the OpenAPI document.
|
|
1094
|
+
|
|
1095
|
+
Written by hand because FastAPI describes one body per operation and this
|
|
1096
|
+
operation has two: inline JSON, or the same object in a ``request`` part
|
|
1097
|
+
beside an uploaded file. F-57 requires the specification to be accurate,
|
|
1098
|
+
and an omitted alternative is an inaccuracy a client discovers at runtime.
|
|
1099
|
+
"""
|
|
1100
|
+
reference = {"$ref": "#/components/schemas/CreateJobRequest"}
|
|
1101
|
+
return {
|
|
1102
|
+
"required": True,
|
|
1103
|
+
"content": {
|
|
1104
|
+
"application/json": {"schema": reference},
|
|
1105
|
+
"multipart/form-data": {
|
|
1106
|
+
"schema": {
|
|
1107
|
+
"type": "object",
|
|
1108
|
+
"required": ["request"],
|
|
1109
|
+
"properties": {
|
|
1110
|
+
"request": {
|
|
1111
|
+
"type": "string",
|
|
1112
|
+
"description": (
|
|
1113
|
+
"The CreateJobRequest object, JSON-encoded, with 'text' omitted."
|
|
1114
|
+
),
|
|
1115
|
+
},
|
|
1116
|
+
"file": {
|
|
1117
|
+
"type": "string",
|
|
1118
|
+
"format": "binary",
|
|
1119
|
+
"description": "A TXT or Markdown file to be validated and spoken.",
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
}
|