Files
UmbraTrans/umbratrans_web/routes_controller.py
umbra audio fbbe23d7f9 Add output generation, cover image management, and back-translation tool
- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
2026-10-25 15:27:02 -07:00

260 lines
7.5 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""``/api`` controller-integration endpoints for the UmbraAudio node.
These endpoints let the UmbraAudio system (audio node) push data into the
UmbraTrans controller and retrieve work state back. They are separate from
``/api/workspace`` (the member's dashboard view) and ``/api/uadmin`` (the
administrator's view) because they serve a machine-to-machine integration
contract rather than a human UI.
Endpoints:
* ``POST /api/orgs`` — create an organization
* ``POST /api/programs`` — create a program (project)
* ``POST /api/speakers/sync`` — sync speakers from the audio node's speakers.json
* ``POST /api/projects/{id}/translations`` — bulk upsert utterance translations
* ``GET /api/projects/{id}/translations/{language_code}`` — get translations
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from .app import get_service, require_principal
from .models import Principal
from .schemas import (
CoverImageResponse,
OrganizationCreateResponse,
OrganizationCreateRequest,
ProgramCreateResponse,
ProgramCreateRequest,
SpeakerSyncResponse,
SpeakerSyncRequest,
UtteranceTranslationListResponse,
UtteranceTranslationRequest,
)
from .service import AccountService
router = APIRouter()
@router.post("/orgs", response_model=OrganizationCreateResponse)
def create_organization(
payload: OrganizationCreateRequest,
request: Request,
principal: Principal = Depends(require_principal),
) -> OrganizationCreateResponse:
"""Create an organization. Platform administrator only."""
service: AccountService = get_service(request)
return service.create_organization(
principal,
name=payload.name,
slug=payload.slug,
)
@router.post("/programs", response_model=ProgramCreateResponse)
def create_program(
payload: ProgramCreateRequest,
request: Request,
principal: Principal = Depends(require_principal),
) -> ProgramCreateResponse:
"""Create a program (project) inside an organization."""
service: AccountService = get_service(request)
return service.create_program(
principal,
organization_id=payload.organization_id,
name=payload.name,
slug=payload.slug,
source_language_tag=payload.source_language_tag,
)
@router.post("/speakers/sync", response_model=SpeakerSyncResponse)
def sync_speakers(
payload: SpeakerSyncRequest,
request: Request,
principal: Principal = Depends(require_principal),
) -> SpeakerSyncResponse:
"""Sync speaker data from the audio node's speakers.json.
The audio node posts its full speakers.json payload. The controller
stores it in the translations directory's speakers.json (for the
file-based pipeline) and in the in-memory job store (for cross-system
consistency).
"""
from uuid import UUID
service: AccountService = get_service(request)
# The speaker sync targets the project that the caller's session is
# scoped to. The caller must have selected a project context; we derive
# it from the workspace snapshot.
snapshot = service.workspace(principal)
projects = snapshot.projects
if not projects:
from .errors import NotFoundError
raise NotFoundError("project_not_found")
# Use the first accessible project as the sync target. The caller should
# target a specific project; this is a convenience for single-project
# deployments.
project = projects[0]
result = service.sync_speakers(
principal,
project.id,
[s.model_dump() for s in payload.speakers],
)
return SpeakerSyncResponse(
project_id=project.id,
created=result["created"],
updated=result["updated"],
aliases_added=result["aliases_added"],
)
@router.post("/projects/{project_id}/translations", response_model=dict)
def upsert_utterance_translations(
project_id: str,
payload: UtteranceTranslationRequest,
request: Request,
principal: Principal = Depends(require_principal),
) -> dict:
"""Bulk upsert utterance translations for one language.
The audio node posts translated text for every utterance in a chapter
or book. The controller stores it keyed by (utterance_id, language_code)
so the review UI can display it.
"""
from uuid import UUID
service: AccountService = get_service(request)
upserted = service.upsert_utterance_translations(
principal,
UUID(str(project_id)),
payload.language_code,
payload.language_name,
[t.model_dump() for t in payload.translations],
)
return {"ok": True, "upserted": upserted}
@router.get(
"/projects/{project_id}/translations/{language_code}",
response_model=UtteranceTranslationListResponse,
)
def get_utterance_translations(
project_id: str,
language_code: str,
request: Request,
principal: Principal = Depends(require_principal),
) -> UtteranceTranslationListResponse:
"""Get all utterance translations for one project and language."""
from uuid import UUID
service: AccountService = get_service(request)
return service.get_utterance_translations(
principal,
UUID(str(project_id)),
language_code,
)
@router.get(
"/projects/{project_id}/cover",
response_model=CoverImageResponse,
)
def get_cover_image(
project_id: str,
request: Request,
language_tag: str | None = None,
principal: Principal = Depends(require_principal),
) -> CoverImageResponse:
"""Get cover image info for a project."""
service: AccountService = get_service(request)
from uuid import UUID
return service.get_cover_image(
principal,
UUID(str(project_id)),
language_tag,
)
@router.post(
"/projects/{project_id}/cover",
response_model=CoverImageResponse,
)
async def set_cover_image(
project_id: str,
request: Request,
language_tag: str | None = None,
principal: Principal = Depends(require_principal),
) -> CoverImageResponse:
"""Save cover image for a project. Uploads image/png data in the request body."""
service: AccountService = get_service(request)
from uuid import UUID
image_data = await request.body()
return service.set_cover_image(
principal,
UUID(str(project_id)),
language_tag,
image_data,
content_type=request.headers.get("content-type", "image/png"),
)
@router.delete(
"/projects/{project_id}/cover",
response_model=CoverImageResponse,
)
def delete_cover_image(
project_id: str,
request: Request,
language_tag: str | None = None,
principal: Principal = Depends(require_principal),
) -> CoverImageResponse:
"""Delete cover image for a project."""
service: AccountService = get_service(request)
from uuid import UUID
return service.delete_cover_image(
principal,
UUID(str(project_id)),
language_tag,
)
@router.post(
"/projects/{project_id}/output/{format}",
response_model=dict,
)
async def queue_output_generation(
project_id: str,
format: str,
request: Request,
principal: Principal = Depends(require_principal),
) -> dict:
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
service: AccountService = get_service(request)
from uuid import UUID
body = await request.json()
language_tag = body.get("language_tag")
return service.queue_output(
principal,
UUID(str(project_id)),
language_tag,
format,
)