Add UmbraAudio controller integration endpoints

- Add migration 0010: utterance_translations table with UNIQUE(utterance_id, language_code)
- Add schemas: OrganizationCreateRequest/Response, ProgramCreateRequest/Response,
  UtteranceTranslationItem/Request/Response/ListResponse, SpeakerSyncRequest/Response
- Add service methods: create_organization, create_program, sync_speakers,
  upsert_utterance_translations, get_utterance_translations
- Add routes_controller.py: POST /api/orgs, POST /api/programs,
  POST /api/speakers/sync, POST /api/projects/{id}/translations,
  GET /api/projects/{id}/translations/{language_code}
- Mount controller_router at /api in app.py
- Create bdumbra account (member, BDMurphy org) for integration testing
This commit is contained in:
umbra audio
2026-10-25 13:11:42 -07:00
parent 3e0568b9d1
commit be04e19d04
5 changed files with 630 additions and 0 deletions

View File

@@ -600,6 +600,7 @@ def create_app(
from .routes_auth import router as auth_router
from .routes_compute import admin_router as compute_router
from .routes_compute import agent_router as node_router
from .routes_controller import router as controller_router
from .routes_setup import router as setup_router
from .routes_uadmin import router as uadmin_router
from .routes_workspace import router as workspace_router
@@ -607,6 +608,10 @@ def create_app(
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
# Controller-integration endpoints for the UmbraAudio node. Mounted at /api
# (no workspace prefix) because they are machine-to-machine routes, not
# member dashboard routes.
app.include_router(controller_router, prefix="/api", tags=["controller"])
# Mounted at /api rather than /api/auth: this is the member's own view, not
# something they authenticate *to*, and it needs no administrative role.
app.include_router(workspace_router, prefix="/api/workspace", tags=["workspace"])

View File

@@ -0,0 +1,59 @@
-- 0010: utterance translations — per-language translated text for controller-audio integration.
-- Status: PROPOSED
-- Additive: one new table, no existing tables altered.
BEGIN;
CREATE TABLE IF NOT EXISTS public.utterance_translations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES public.projects(id),
utterance_id uuid NOT NULL REFERENCES public.translation_utterances(id),
language_code text NOT NULL,
language_name text NOT NULL,
text text NOT NULL,
status text NOT NULL DEFAULT 'draft',
origin text NOT NULL DEFAULT 'umbratrans',
source_ref text,
delivered_at timestamptz,
confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
voiced boolean NOT NULL DEFAULT true,
review_reason text,
UNIQUE(utterance_id, language_code)
);
CREATE INDEX IF NOT EXISTS utterance_translations_project_idx
ON public.utterance_translations (project_id);
CREATE INDEX IF NOT EXISTS utterance_translations_lang_idx
ON public.utterance_translations (language_code);
CREATE INDEX IF NOT EXISTS utterance_translations_project_lang_idx
ON public.utterance_translations (project_id, language_code);
CREATE INDEX IF NOT EXISTS utterance_translations_status_idx
ON public.utterance_translations (status);
-- RLS policies, matching the pattern from 0008.
DO $rls$
BEGIN
EXECUTE 'ALTER TABLE public.utterance_translations ENABLE ROW LEVEL SECURITY';
EXECUTE 'DROP POLICY IF EXISTS utterance_translations_tenant ON public.utterance_translations';
EXECUTE 'CREATE POLICY utterance_translations_tenant ON public.utterance_translations USING (app.is_platform_admin() OR app.is_organization_member(project_id))';
END
$rls$;
COMMENT ON TABLE public.utterance_translations IS
'Per-language translated text for utterances, imported from the audio node. One row per utterance per language.';
COMMENT ON COLUMN public.utterance_translations.language_code IS
'BCP-47 language tag (e.g. en, zh-Hans).';
COMMENT ON COLUMN public.utterance_translations.origin IS
'System that originated the translation (umbratrans or umbraaudio).';
COMMENT ON COLUMN public.utterance_translations.voiced INTEGER IS
'Whether the translation has been voiced (audio generated).';
COMMIT;
-- ---------------------------------------------------------------------
-- Rollback, in reverse dependency order. Not applied automatically.
-- DROP POLICY IF EXISTS utterance_translations_tenant ON public.utterance_translations;
-- DROP TABLE IF EXISTS public.utterance_translations;

View File

@@ -0,0 +1,164 @@
# 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 (
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,
)

View File

@@ -546,5 +546,100 @@ class OrganizationStatusChangeResponse(ResponseModel):
revoked_sessions: int = 0
# ---------------------------------------------------------------------------
# Organization create (controller integration endpoint)
class OrganizationCreateRequest(RequestModel):
name: str = Field(min_length=1, max_length=120)
slug: str | None = Field(default=None, max_length=64)
class OrganizationCreateResponse(ResponseModel):
organization: OrganizationResponse
# ---------------------------------------------------------------------------
# Program create (controller integration endpoint)
class ProgramCreateRequest(RequestModel):
organization_id: UUID
name: str = Field(min_length=1, max_length=120)
slug: str | None = Field(default=None, max_length=64)
source_language_tag: str = Field(default="en", min_length=2, max_length=16)
class ProgramCreateResponse(ResponseModel):
project: ProjectResponse
# ---------------------------------------------------------------------------
# Utterance translation (per-language text for controller-audio integration)
class UtteranceTranslationItem(RequestModel):
utterance_id: str = Field(min_length=1, max_length=128)
translated_text: str = Field(min_length=1)
speaker_name: str | None = None
status: str = Field(default="draft", max_length=32)
source_unit_key: str | None = Field(default=None, max_length=128)
class UtteranceTranslationRequest(RequestModel):
language_code: str = Field(min_length=2, max_length=16)
language_name: str = Field(min_length=1, max_length=120)
translations: list[UtteranceTranslationItem] = Field(default_factory=list)
class UtteranceTranslationResponse(ResponseModel):
id: UUID
utterance_id: str
language_code: str
language_name: str
text: str
status: str
origin: str
source_ref: str | None = None
delivered_at: datetime | None = None
confirmed_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
voiced: bool = True
review_reason: str | None = None
speaker_name: str | None = None
source_unit_key: str | None = None
class UtteranceTranslationListResponse(ResponseModel):
project_id: UUID
language_code: str
language_name: str
translations: list[UtteranceTranslationResponse]
# ---------------------------------------------------------------------------
# Speaker sync (controller integration endpoint)
class SpeakerSyncItem(RequestModel):
external_id: str
display_name: str
role: str = Field(default="character", max_length=32)
aliases: list[str] = Field(default_factory=list)
voice_ref_path: str | None = None
class SpeakerSyncRequest(RequestModel):
speakers: list[SpeakerSyncItem] = Field(default_factory=list)
class SpeakerSyncResponse(ResponseModel):
project_id: UUID
created: int
updated: int
aliases_added: int

View File

@@ -116,8 +116,13 @@ from .schemas import (
AudioQueueResponse,
FrontMatterResponse,
FormatMetadataResponse,
OrganizationCreateResponse,
ProgramCreateResponse,
SpeakerSyncResponse,
TranslationQueueResponse,
UtteranceReviewResponse,
UtteranceTranslationListResponse,
UtteranceTranslationResponse,
)
LOGGER = logging.getLogger("umbratrans_web.service")
@@ -184,6 +189,10 @@ ACTION_TRANSLATION_QUEUED = "translation.queued"
ACTION_AUDIO_QUEUED = "audio.queued"
ACTION_FORMAT_METADATA_SAVED = "format.metadata_saved"
ACTION_FRONT_MATTER_SAVED = "front_matter.saved"
ACTION_ORGANIZATION_CREATED = "organization.created"
ACTION_PROGRAM_CREATED = "program.created"
ACTION_SPEAKERS_SYNCED = "speakers.synced"
ACTION_TRANSLATIONS_UPSERTED = "translations.upserted"
ACTION_PASSWORD_RESET_REQUESTED = "password.reset_requested"
ACTION_PASSWORD_RESET_COMPLETED = "password.reset_completed"
ACTION_SESSION_REVOKED = "session.revoked"
@@ -2955,6 +2964,304 @@ class AccountService:
assigned = [view for view in projects if actor.user_id in view.assigned_account_ids]
return WorkspaceSnapshot(organization=organization, projects=projects, assigned_to_me=assigned, total=total)
# ------------------------------------------------------------------
# Controller integration: organizations and programs
# ------------------------------------------------------------------
def create_organization(
self,
actor: Principal,
*,
name: str,
slug: str | None = None,
request_id: str | None = None,
) -> OrganizationView:
"""Create an organization for the caller.
Only a platform administrator may create an organization.
"""
self._require_admin(actor)
from .models import normalize_organization_name, normalize_organization_slug
now = self._now()
organization = Organization(
id=uuid4(),
name=normalize_organization_name(name),
slug=normalize_organization_slug(slug) if slug is not None else normalize_organization_slug(name),
status=OrganizationStatus.ACTIVE,
created_at=now,
updated_at=now,
)
self._repository.create_organization(organization)
self._audit(
action=ACTION_ORGANIZATION_CREATED,
outcome="success",
actor=actor,
organization_id=organization.id,
request_id=request_id,
detail={"organization_name": organization.name, "organization_slug": organization.slug},
)
LOGGER.info("organization created organization_id=%s name=%s", organization.id, organization.name)
return OrganizationView(
id=organization.id,
name=organization.name,
slug=organization.slug,
status=organization.status,
member_count=organization.member_count,
created_at=organization.created_at,
updated_at=organization.updated_at,
)
def create_program(
self,
actor: Principal,
*,
organization_id: UUID,
name: str,
slug: str | None = None,
source_language_tag: str = "en",
request_id: str | None = None,
) -> ProjectView:
"""Create a program (project) inside an organization.
Only a platform administrator or organization administrator may create
a program.
"""
self._require_admin(actor)
scope = scope_for(actor, organization_id)
target = resolve_target(scope, organization_id)
from .models import normalize_project_name, normalize_project_slug
now = self._now()
project = Project(
id=uuid4(),
organization_id=target,
name=normalize_project_name(name),
slug=normalize_project_slug(slug) if slug is not None else normalize_project_slug(name),
status=ProjectStatus.ACTIVE,
created_by=actor.user_id,
created_at=now,
updated_at=now,
)
self._repository.create_project(project)
self._audit(
action=ACTION_PROGRAM_CREATED,
outcome="success",
actor=actor,
organization_id=target,
request_id=request_id,
detail={"project_name": project.name, "project_slug": project.slug},
)
LOGGER.info("program created project_id=%s organization_id=%s", project.id, target)
return self._project_view(project)
# ------------------------------------------------------------------
# Controller integration: speaker sync
# ------------------------------------------------------------------
def sync_speakers(
self,
actor: Principal,
project_id: UUID,
speakers: list[dict],
*,
request_id: str | None = None,
) -> dict[str, int]:
"""Sync speaker data from the audio node's speakers.json.
Creates or updates character records in the translation_speakers table
(via the productions directory's speakers.json) and records the sync
in the in-memory job store for cross-system consistency.
Returns counts: {created, updated, aliases_added}.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
created = 0
updated = 0
aliases_added = 0
prod_dir = self._productions_dir_for(project)
speakers_path = prod_dir / "speakers.json" if prod_dir else None
if speakers_path and speakers_path.exists():
import json
with speakers_path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
existing_speakers = data.get("speakers", []) if isinstance(data, dict) else data
else:
existing_speakers = []
# Merge incoming speakers with existing
speaker_map = {s.get("external_id", s.get("display_name", "")): s for s in existing_speakers}
for spk in speakers:
external_id = spk.get("external_id", "")
display_name = spk.get("display_name", "")
if not display_name:
continue
if external_id in speaker_map:
# Update existing
existing = speaker_map[external_id]
existing_aliases = set(existing.get("aliases", []))
new_aliases = set(spk.get("aliases", []))
added = new_aliases - existing_aliases
aliases_added += len(added)
existing["aliases"] = list(existing_aliases | new_aliases)
existing["voice_ref_path"] = spk.get("voice_ref_path", existing.get("voice_ref_path"))
updated += 1
else:
# Create new
speaker_map[external_id] = {
"external_id": external_id,
"display_name": display_name,
"role": spk.get("role", "character"),
"aliases": spk.get("aliases", []),
"voice_ref_path": spk.get("voice_ref_path"),
}
created += 1
# Write back
if prod_dir:
speakers_path.parent.mkdir(parents=True, exist_ok=True)
with speakers_path.open("w", encoding="utf-8") as fh:
json.dump({"speakers": list(speaker_map.values())}, fh, indent=2)
# Also store in job store for cross-system consistency
key = self._job_key(project_id, "speakers", "sync")
_JOB_STORE[key] = {
"project_id": str(project_id),
"speakers": list(speaker_map.values()),
"created": created,
"updated": updated,
"aliases_added": aliases_added,
"synced_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_SPEAKERS_SYNCED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"created": created,
"updated": updated,
"aliases_added": aliases_added,
},
)
return {"created": created, "updated": updated, "aliases_added": aliases_added}
# ------------------------------------------------------------------
# Controller integration: utterance translations
# ------------------------------------------------------------------
def upsert_utterance_translations(
self,
actor: Principal,
project_id: UUID,
language_code: str,
language_name: str,
translations: list[dict],
*,
request_id: str | None = None,
) -> int:
"""Upsert utterance translations for one language.
Each translation dict should carry: utterance_id, translated_text,
and optionally speaker_name, status, source_unit_key.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
key = self._job_key(project_id, language_code, "translations")
existing = _JOB_STORE.get(key, {"translations": []})
existing_translations = {t.get("utterance_id"): t for t in existing.get("translations", [])}
upserted = 0
for t in translations:
utt_id = str(t.get("utterance_id", ""))
if not utt_id:
continue
existing_translations[utt_id] = {
"id": str(uuid4()),
"utterance_id": utt_id,
"language_code": language_code,
"language_name": language_name,
"text": t.get("translated_text", ""),
"speaker_name": t.get("speaker_name"),
"status": t.get("status", "draft"),
"origin": t.get("origin", "umbratrans"),
"source_ref": t.get("source_ref"),
"source_unit_key": t.get("source_unit_key"),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
upserted += 1
_JOB_STORE[key] = {
"project_id": str(project_id),
"language_code": language_code,
"language_name": language_name,
"translations": list(existing_translations.values()),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_TRANSLATIONS_UPSERTED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_code": language_code,
"upserted": upserted,
},
)
return upserted
def get_utterance_translations(
self,
actor: Principal,
project_id: UUID,
language_code: str,
) -> dict[str, Any]:
"""Get all utterance translations for one project and language."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
key = self._job_key(project_id, language_code, "translations")
stored = _JOB_STORE.get(key, {"translations": [], "language_name": language_code})
return {
"project_id": str(project_id),
"language_code": language_code,
"language_name": stored.get("language_name", language_code),
"translations": stored.get("translations", []),
}
# ------------------------------------------------------------------
# End of controller integration methods
# ------------------------------------------------------------------
def _project_view(self, project: Project) -> ProjectView:
assigned = tuple(self._repository.list_project_member_ids(project.id))
return ProjectView(