Spaces:
Running
Running
Upload 340 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +70 -0
- Dockerfile +3 -1
- README.md +69 -7
- app/ai/__init__.py +1 -0
- app/ai/api.py +87 -0
- app/ai/schemas.py +171 -0
- app/ai/service.py +378 -0
- app/analytics/__init__.py +1 -0
- app/analytics/api.py +152 -0
- app/analytics/errors.py +18 -0
- app/analytics/models.py +178 -0
- app/analytics/repository.py +385 -0
- app/analytics/schemas.py +140 -0
- app/analytics/service.py +379 -0
- app/analytics/workers/__init__.py +1 -0
- app/analytics/workers/sync.py +99 -0
- app/api/api_keys.py +7 -1
- app/api/generation.py +118 -0
- app/api/media.py +21 -1
- app/api/social.py +239 -41
- app/brand/__init__.py +2 -0
- app/brand/api.py +99 -0
- app/brand/capabilities.py +26 -0
- app/brand/errors.py +9 -0
- app/brand/models.py +165 -0
- app/brand/models/__init__.py +3 -0
- app/brand/models/brand.py +64 -0
- app/brand/repositories/brand_repository.py +138 -0
- app/brand/repository.py +603 -0
- app/brand/schemas.py +20 -0
- app/brand/service.py +636 -0
- app/brand/services/brand_service.py +64 -0
- app/brand/services/validation_service.py +29 -0
- app/brand/validation.py +30 -0
- app/container.py +236 -13
- app/copilot/__init__.py +1 -0
- app/copilot/actions.py +853 -0
- app/copilot/api.py +121 -0
- app/copilot/errors.py +36 -0
- app/copilot/models.py +66 -0
- app/copilot/planner.py +627 -0
- app/copilot/repository.py +196 -0
- app/copilot/schemas.py +439 -0
- app/copilot/service.py +499 -0
- app/core/config.py +160 -19
- app/core/database_url.py +18 -0
- app/generation/__init__.py +5 -0
- app/generation/domain/__init__.py +23 -0
- app/generation/domain/capabilities.py +35 -0
- app/generation/domain/enums.py +91 -0
.env.example
CHANGED
|
@@ -1,4 +1,13 @@
|
|
| 1 |
APP_NAME=MediaRouter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
TEMP_DIR=./temp
|
| 3 |
OUTPUT_DIR=./outputs
|
| 4 |
TEMPLATE_DIR=./app/templates/categories
|
|
@@ -17,6 +26,13 @@ FFMPEG_BINARY=ffmpeg
|
|
| 17 |
FFPROBE_BINARY=ffprobe
|
| 18 |
AUTH_ENABLED=true
|
| 19 |
DATABASE_URL=sqlite+aiosqlite:///./data/mediarouter.db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
AUTH_ROLE_SCOPES={}
|
| 21 |
AUTH_BOOTSTRAP_KEY_HASH=
|
| 22 |
AUTH_BOOTSTRAP_KEY_PREFIX=
|
|
@@ -37,6 +53,14 @@ MCP_STDIO_API_KEY=
|
|
| 37 |
SOCIAL_ENABLED=true
|
| 38 |
# Example: postgresql+asyncpg://postgres:password@db.example:5432/postgres
|
| 39 |
SOCIAL_DATABASE_URL=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
SOCIAL_AUTO_MIGRATE=false
|
| 41 |
SOCIAL_WORKER_ENABLED=true
|
| 42 |
SOCIAL_SCHEDULER_INTERVAL_SECONDS=30
|
|
@@ -53,6 +77,47 @@ SUPABASE_URL=
|
|
| 53 |
SUPABASE_SERVICE_ROLE_KEY=
|
| 54 |
SUPABASE_VAULT_ENABLED=false
|
| 55 |
SOCIAL_OAUTH_REDIRECT_BASE_URL=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
GOOGLE_CLIENT_ID=
|
| 57 |
GOOGLE_CLIENT_SECRET=
|
| 58 |
# YouTube Data API v3 worker controls. Keep the client secret backend-only.
|
|
@@ -63,6 +128,11 @@ YOUTUBE_REQUEST_TIMEOUT_SECONDS=60
|
|
| 63 |
YOUTUBE_PROCESSING_POLL_SECONDS=30
|
| 64 |
META_CLIENT_ID=
|
| 65 |
META_CLIENT_SECRET=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
TIKTOK_CLIENT_KEY=
|
| 67 |
TIKTOK_CLIENT_SECRET=
|
| 68 |
# Exact backend callback registered under TikTok Login Kit. It must be:
|
|
|
|
| 1 |
APP_NAME=MediaRouter
|
| 2 |
+
APP_VERSION=1.0.0
|
| 3 |
+
# Docker sets this to production. Local development keeps development so the
|
| 4 |
+
# established SQLite workflow remains available.
|
| 5 |
+
APP_ENVIRONMENT=development
|
| 6 |
+
HOST=0.0.0.0
|
| 7 |
+
PORT=7860
|
| 8 |
+
# Comma-separated exact origins. Production requires at least one HTTPS origin
|
| 9 |
+
# such as the separately hosted Vercel frontend; wildcard origins are rejected.
|
| 10 |
+
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
| 11 |
TEMP_DIR=./temp
|
| 12 |
OUTPUT_DIR=./outputs
|
| 13 |
TEMPLATE_DIR=./app/templates/categories
|
|
|
|
| 26 |
FFPROBE_BINARY=ffprobe
|
| 27 |
AUTH_ENABLED=true
|
| 28 |
DATABASE_URL=sqlite+aiosqlite:///./data/mediarouter.db
|
| 29 |
+
# SQLite initializes this schema locally. PostgreSQL must receive the SQL files
|
| 30 |
+
# under app/security/migrations/ through your deployment migration process.
|
| 31 |
+
SECURITY_AUTO_MIGRATE=false
|
| 32 |
+
# PostgreSQL only: backend-only BYPASSRLS role used for authoritative users,
|
| 33 |
+
# memberships, API-key principals, and canonical asset records.
|
| 34 |
+
SECURITY_DATABASE_ROLE=mediarouter_security_service
|
| 35 |
+
SECURITY_ENFORCE_RLS=true
|
| 36 |
AUTH_ROLE_SCOPES={}
|
| 37 |
AUTH_BOOTSTRAP_KEY_HASH=
|
| 38 |
AUTH_BOOTSTRAP_KEY_PREFIX=
|
|
|
|
| 53 |
SOCIAL_ENABLED=true
|
| 54 |
# Example: postgresql+asyncpg://postgres:password@db.example:5432/postgres
|
| 55 |
SOCIAL_DATABASE_URL=
|
| 56 |
+
# Required for PostgreSQL production. This URL must authenticate as a normal
|
| 57 |
+
# non-owner/non-BYPASSRLS API role. Do not use a Supabase service-role URL here.
|
| 58 |
+
SOCIAL_TENANT_DATABASE_ROLE=mediarouter_tenant
|
| 59 |
+
# Trusted backend-only worker connection. It must use the explicitly named
|
| 60 |
+
# BYPASSRLS role and must never be exposed through REST, MCP, n8n, SDKs, or UI.
|
| 61 |
+
SOCIAL_WORKER_DATABASE_URL=
|
| 62 |
+
SOCIAL_WORKER_DATABASE_ROLE=mediarouter_social_worker
|
| 63 |
+
SOCIAL_ENFORCE_RLS=true
|
| 64 |
SOCIAL_AUTO_MIGRATE=false
|
| 65 |
SOCIAL_WORKER_ENABLED=true
|
| 66 |
SOCIAL_SCHEDULER_INTERVAL_SECONDS=30
|
|
|
|
| 77 |
SUPABASE_SERVICE_ROLE_KEY=
|
| 78 |
SUPABASE_VAULT_ENABLED=false
|
| 79 |
SOCIAL_OAUTH_REDIRECT_BASE_URL=
|
| 80 |
+
|
| 81 |
+
# Provider-neutral generation runtime. WAN and FLUX are optional; neither is
|
| 82 |
+
# enabled merely by the shared timeout/retry settings below.
|
| 83 |
+
GENERATION_ENABLED=true
|
| 84 |
+
GENERATION_JOB_RETRY_LIMIT=3
|
| 85 |
+
# Shared remote AI worker client defaults. No worker is configured by these
|
| 86 |
+
# values alone; do not add browser-visible worker URLs or credentials here.
|
| 87 |
+
AI_WORKER_CONNECT_TIMEOUT_SECONDS=10
|
| 88 |
+
AI_WORKER_REQUEST_TIMEOUT_SECONDS=60
|
| 89 |
+
AI_WORKER_READ_TIMEOUT_SECONDS=300
|
| 90 |
+
AI_WORKER_MAX_RETRIES=3
|
| 91 |
+
AI_WORKER_RETRY_BACKOFF_SECONDS=0.5
|
| 92 |
+
# Optional authenticated WAN 2.2 image-to-video worker. Set both values only
|
| 93 |
+
# in backend/Hugging Face deployment secrets; never expose them to a browser,
|
| 94 |
+
# MCP client, n8n node, SDK output, or logs. WAN stays unavailable if either
|
| 95 |
+
# value is omitted or malformed, without blocking application startup.
|
| 96 |
+
WAN_SPACE_URL=
|
| 97 |
+
WAN_SPACE_TOKEN=
|
| 98 |
+
# Optional authenticated FLUX.2 Klein image-generation worker. Set both
|
| 99 |
+
# backend-only values in deployment secrets; never expose either value in a
|
| 100 |
+
# browser, MCP/n8n request, SDK result, audit record, or log.
|
| 101 |
+
FLUX_SPACE_URL=
|
| 102 |
+
FLUX_SPACE_TOKEN=
|
| 103 |
+
GENERATION_WORKER_ENABLED=true
|
| 104 |
+
GENERATION_WORKER_INTERVAL_SECONDS=5
|
| 105 |
+
GENERATION_WORKER_POLL_BACKOFF_SECONDS=2
|
| 106 |
+
GENERATION_WORKER_BATCH_SIZE=8
|
| 107 |
+
GENERATION_JOB_STALE_AFTER_SECONDS=900
|
| 108 |
+
# Content Studio persistence/render limits. Editor state and render jobs are
|
| 109 |
+
# durable PostgreSQL records; render workers use bounded temporary files only.
|
| 110 |
+
EDITOR_STATE_MAX_BYTES=1048576
|
| 111 |
+
RENDER_WORKER_ENABLED=true
|
| 112 |
+
RENDER_WORKER_INTERVAL_SECONDS=2
|
| 113 |
+
RENDER_JOB_STALE_AFTER_SECONDS=900
|
| 114 |
+
RENDER_JOB_TIMEOUT_SECONDS=7200
|
| 115 |
+
RENDER_JOB_RETRY_LIMIT=2
|
| 116 |
+
RENDER_MAX_ACTIVE_JOBS_PER_PROJECT=1
|
| 117 |
+
RENDER_MAX_TRACKS=32
|
| 118 |
+
RENDER_MAX_CLIPS=500
|
| 119 |
+
RENDER_MAX_DURATION_SECONDS=3600
|
| 120 |
+
RENDER_MAX_INPUT_BYTES=4294967296
|
| 121 |
GOOGLE_CLIENT_ID=
|
| 122 |
GOOGLE_CLIENT_SECRET=
|
| 123 |
# YouTube Data API v3 worker controls. Keep the client secret backend-only.
|
|
|
|
| 128 |
YOUTUBE_PROCESSING_POLL_SECONDS=30
|
| 129 |
META_CLIENT_ID=
|
| 130 |
META_CLIENT_SECRET=
|
| 131 |
+
# Preferred Meta application names. META_CLIENT_* remains a compatibility
|
| 132 |
+
# alias for deployments created before this configuration contract.
|
| 133 |
+
META_APP_ID=
|
| 134 |
+
META_APP_SECRET=
|
| 135 |
+
META_GRAPH_API_VERSION=v25.0
|
| 136 |
TIKTOK_CLIENT_KEY=
|
| 137 |
TIKTOK_CLIENT_SECRET=
|
| 138 |
# Exact backend callback registered under TikTok Login Kit. It must be:
|
Dockerfile
CHANGED
|
@@ -6,9 +6,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 6 |
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 7 |
HF_HOME=/home/user/.cache/huggingface \
|
| 8 |
APP_NAME=MediaRouter \
|
|
|
|
| 9 |
TEMP_DIR=/app/temp \
|
| 10 |
OUTPUT_DIR=/app/outputs \
|
| 11 |
-
DATABASE_URL=sqlite+aiosqlite:////app/data/mediarouter.db \
|
| 12 |
PORT=7860
|
| 13 |
|
| 14 |
RUN apt-get update \
|
|
@@ -39,6 +39,8 @@ USER user
|
|
| 39 |
|
| 40 |
EXPOSE 7860
|
| 41 |
|
|
|
|
|
|
|
| 42 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 43 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=4)" || exit 1
|
| 44 |
|
|
|
|
| 6 |
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 7 |
HF_HOME=/home/user/.cache/huggingface \
|
| 8 |
APP_NAME=MediaRouter \
|
| 9 |
+
APP_ENVIRONMENT=production \
|
| 10 |
TEMP_DIR=/app/temp \
|
| 11 |
OUTPUT_DIR=/app/outputs \
|
|
|
|
| 12 |
PORT=7860
|
| 13 |
|
| 14 |
RUN apt-get update \
|
|
|
|
| 39 |
|
| 40 |
EXPOSE 7860
|
| 41 |
|
| 42 |
+
STOPSIGNAL SIGTERM
|
| 43 |
+
|
| 44 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 45 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=4)" || exit 1
|
| 46 |
|
README.md
CHANGED
|
@@ -57,6 +57,7 @@ media-api/
|
|
| 57 |
├── app/
|
| 58 |
│ ├── api/ # versioned, thin FastAPI routes
|
| 59 |
│ ├── core/ # settings, errors, logging, response models
|
|
|
|
| 60 |
│ ├── mcp/ # MCP server, registry, tools, resources, prompts
|
| 61 |
│ ├── models/ # InputMedia and request/result models
|
| 62 |
│ ├── operations/ # reusable FFmpeg operation functions
|
|
@@ -83,6 +84,19 @@ media-api/
|
|
| 83 |
|
| 84 |
The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
Each request creates `TEMP_DIR/<uuid>/{uploads,outputs,logs}`. Completed files are atomically moved to `OUTPUT_DIR/<uuid>` so they can be streamed. Both locations expire after `CLEANUP_MINUTES`; active requests are protected from the cleanup worker.
|
| 87 |
|
| 88 |
## Run locally
|
|
@@ -110,19 +124,29 @@ docker run --rm -p 7860:7860 --env-file .env media-api
|
|
| 110 |
|
| 111 |
The image uses `python:3.10-slim`, installs FFmpeg, FFprobe, ImageMagick and the system libraries needed by CTranslate2/faster-whisper, clears apt and pip caches, runs as UID 1000, exposes port 7860, and includes a container health check.
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
## Deploy to Hugging Face Spaces
|
| 114 |
|
| 115 |
1. Create a new Space and select **Docker** as the SDK.
|
| 116 |
2. Push the contents of this directory to the Space repository. Keep the YAML block at the top of this README; `app_port` is already `7860`.
|
| 117 |
-
3.
|
| 118 |
-
4.
|
| 119 |
-
5.
|
|
|
|
|
|
|
| 120 |
|
| 121 |
If using the supplied `media-api-huggingface.zip`, extract it first and push the extracted files so `Dockerfile` and this `README.md` are at the Space repository root. Do not commit the ZIP as the only repository file.
|
| 122 |
|
| 123 |
Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is managed in-process by `MAX_WORKERS`; multiple Uvicorn workers duplicate Whisper models and memory.
|
| 124 |
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
## Connect the Vercel frontend to Hugging Face
|
| 128 |
|
|
@@ -155,13 +179,21 @@ The bootstrap fields are optional after the first successful start. If a Space f
|
|
| 155 |
Recommended backend deployment settings are:
|
| 156 |
|
| 157 |
```env
|
|
|
|
| 158 |
BASE_URL=https://basyx-mediarouter.hf.space
|
| 159 |
-
DATABASE_URL=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
AUTH_ROLE_SCOPES={}
|
| 161 |
MCP_STDIO_API_KEY=<a-valid-mp_live-or-mp_test-key-if-stdio-MCP-is-enabled>
|
| 162 |
```
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
|
| 166 |
### Vercel environment variables
|
| 167 |
|
|
@@ -284,6 +316,7 @@ Scopes are enforced before route execution. Explicit scopes are combined with th
|
|
| 284 |
| Jobs | `jobs:read`, `jobs:create`, `jobs:cancel` |
|
| 285 |
| Assets | `assets:read`, `assets:write`, `assets:delete` |
|
| 286 |
| MCP | `mcp:read`, `mcp:execute` |
|
|
|
|
| 287 |
| System | `system:read` |
|
| 288 |
| Administration | `admin` |
|
| 289 |
|
|
@@ -1284,6 +1317,12 @@ curl -X POST https://your-space.hf.space/v1/social/posts \
|
|
| 1284 |
|
| 1285 |
| Variable | Default | Purpose |
|
| 1286 |
|---|---:|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1287 |
| `TEMP_DIR` | `./temp` | Request workspaces and in-progress files |
|
| 1288 |
| `OUTPUT_DIR` | `./outputs` | Published files served by download URLs |
|
| 1289 |
| `TEMPLATE_DIR` | built-in `app/templates/categories` | Recursively scanned YAML workflow catalog |
|
|
@@ -1301,7 +1340,10 @@ curl -X POST https://your-space.hf.space/v1/social/posts \
|
|
| 1301 |
| `FFMPEG_BINARY` | `ffmpeg` | FFmpeg executable name/path |
|
| 1302 |
| `FFPROBE_BINARY` | `ffprobe` | FFprobe executable name/path |
|
| 1303 |
| `AUTH_ENABLED` | `true` | Fail-closed API-key enforcement; disable only for isolated development/tests |
|
| 1304 |
-
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/mediarouter.db` |
|
|
|
|
|
|
|
|
|
|
| 1305 |
| `AUTH_ROLE_SCOPES` | `{}` | JSON custom role-to-scope mappings |
|
| 1306 |
| `AUTH_BOOTSTRAP_KEY_HASH` | empty | SHA-256 hash for first-start administrator |
|
| 1307 |
| `AUTH_BOOTSTRAP_KEY_PREFIX` | empty | Safe display prefix matching the bootstrap key |
|
|
@@ -1314,8 +1356,28 @@ curl -X POST https://your-space.hf.space/v1/social/posts \
|
|
| 1314 |
| `AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY` | `107374182400` | Default daily uploaded processing bytes |
|
| 1315 |
| `AUTH_TRUST_PROXY_HEADERS` | `true` | Use first `X-Forwarded-For` address for audits behind HF/Vercel |
|
| 1316 |
| `MCP_STDIO_API_KEY` | empty | Existing API key required by authenticated standalone stdio MCP |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1317 |
| `SOCIAL_ENABLED` | `true` | Enable the additive social domain; existing media APIs remain independent |
|
| 1318 |
| `SOCIAL_DATABASE_URL` | `DATABASE_URL` | Async SQLAlchemy URL; use Supabase/Postgres in production |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
| `SOCIAL_AUTO_MIGRATE` | `false` | Local/test metadata creation only; never use for production migration management |
|
| 1320 |
| `SOCIAL_WORKER_ENABLED` | `true` | Run durable scheduler/publisher claim loop when schema is ready |
|
| 1321 |
| `SOCIAL_SCHEDULER_INTERVAL_SECONDS` | `30` | Scheduler polling interval |
|
|
|
|
| 57 |
├── app/
|
| 58 |
│ ├── api/ # versioned, thin FastAPI routes
|
| 59 |
│ ├── core/ # settings, errors, logging, response models
|
| 60 |
+
│ ├── generation/ # provider-neutral durable generation foundation
|
| 61 |
│ ├── mcp/ # MCP server, registry, tools, resources, prompts
|
| 62 |
│ ├── models/ # InputMedia and request/result models
|
| 63 |
│ ├── operations/ # reusable FFmpeg operation functions
|
|
|
|
| 84 |
|
| 85 |
The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
|
| 86 |
|
| 87 |
+
## Generation foundation
|
| 88 |
+
|
| 89 |
+
MediaRouter includes a tenant-scoped generation request/job foundation at
|
| 90 |
+
`/v1/generation`. Optional `wan` and `flux` providers implement audited WAN
|
| 91 |
+
2.2 image-to-video and FLUX.2 Klein image-generation worker contracts. Each
|
| 92 |
+
model stays unavailable until its backend-only URL/token are configured and
|
| 93 |
+
live readiness verifies its exact worker identity. Requests use an idempotency
|
| 94 |
+
key and may reference only canonical MediaRouter assets by opaque ID. See
|
| 95 |
+
[`docs/generation-foundation.md`](docs/generation-foundation.md),
|
| 96 |
+
[`docs/generation-wan.md`](docs/generation-wan.md), and
|
| 97 |
+
[`docs/generation-flux.md`](docs/generation-flux.md) for the state machine,
|
| 98 |
+
PostgreSQL migration, scopes, worker contracts, and security boundary.
|
| 99 |
+
|
| 100 |
Each request creates `TEMP_DIR/<uuid>/{uploads,outputs,logs}`. Completed files are atomically moved to `OUTPUT_DIR/<uuid>` so they can be streamed. Both locations expire after `CLEANUP_MINUTES`; active requests are protected from the cleanup worker.
|
| 101 |
|
| 102 |
## Run locally
|
|
|
|
| 124 |
|
| 125 |
The image uses `python:3.10-slim`, installs FFmpeg, FFprobe, ImageMagick and the system libraries needed by CTranslate2/faster-whisper, clears apt and pip caches, runs as UID 1000, exposes port 7860, and includes a container health check.
|
| 126 |
|
| 127 |
+
The Docker image sets `APP_ENVIRONMENT=production`. It intentionally has no
|
| 128 |
+
SQLite database default: configure external PostgreSQL/Supabase, apply the
|
| 129 |
+
explicit migrations, and set an exact Vercel CORS origin before startup. See
|
| 130 |
+
[the production deployment gate](docs/production-deployment-gate.md).
|
| 131 |
+
|
| 132 |
## Deploy to Hugging Face Spaces
|
| 133 |
|
| 134 |
1. Create a new Space and select **Docker** as the SDK.
|
| 135 |
2. Push the contents of this directory to the Space repository. Keep the YAML block at the top of this README; `app_port` is already `7860`.
|
| 136 |
+
3. Apply the PostgreSQL migrations in the documented dependency order, using a controlled administrative connection.
|
| 137 |
+
4. Configure the required PostgreSQL/RLS/CORS variables and roles in Space Settings. Keep `SECURITY_AUTO_MIGRATE=false` and `SOCIAL_AUTO_MIGRATE=false`.
|
| 138 |
+
5. Generate the initial administrator key locally with `python -m app.security.cli generate-bootstrap --environment live`. Store `API_KEY` in a password manager. Add only the printed `AUTH_BOOTSTRAP_KEY_HASH`, `AUTH_BOOTSTRAP_KEY_PREFIX`, and `AUTH_BOOTSTRAP_ENVIRONMENT` under **Settings → Secrets**. Do not commit them.
|
| 139 |
+
6. Wait for the Docker build. The first Whisper call downloads the selected model to the Hugging Face cache. Persistent storage is optional for the model cache, but avoids downloading models again after a cold rebuild.
|
| 140 |
+
7. Check the public `https://<owner>-<space>.hf.space/health`, then run `scripts/deployment_smoke.py` against the Space before accepting traffic.
|
| 141 |
|
| 142 |
If using the supplied `media-api-huggingface.zip`, extract it first and push the extracted files so `Dockerfile` and this `README.md` are at the Space repository root. Do not commit the ZIP as the only repository file.
|
| 143 |
|
| 144 |
Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is managed in-process by `MAX_WORKERS`; multiple Uvicorn workers duplicate Whisper models and memory.
|
| 145 |
|
| 146 |
+
Production keys, workspaces, projects, audit records, and rate aggregates live
|
| 147 |
+
in external PostgreSQL/Supabase. SQLite remains a local-development and test
|
| 148 |
+
backend only. Media files in `TEMP_DIR`/`OUTPUT_DIR` are expiring working data;
|
| 149 |
+
see the deployment gate for their filesystem lifecycle.
|
| 150 |
|
| 151 |
## Connect the Vercel frontend to Hugging Face
|
| 152 |
|
|
|
|
| 179 |
Recommended backend deployment settings are:
|
| 180 |
|
| 181 |
```env
|
| 182 |
+
APP_ENVIRONMENT=production
|
| 183 |
BASE_URL=https://basyx-mediarouter.hf.space
|
| 184 |
+
DATABASE_URL=postgresql+asyncpg://<security-role>:<password>@<host>/<database>
|
| 185 |
+
SECURITY_DATABASE_ROLE=mediarouter_security_service
|
| 186 |
+
SECURITY_ENFORCE_RLS=true
|
| 187 |
+
SECURITY_AUTO_MIGRATE=false
|
| 188 |
+
CORS_ALLOWED_ORIGINS=https://<your-vercel-project>.vercel.app
|
| 189 |
+
SOCIAL_AUTO_MIGRATE=false
|
| 190 |
AUTH_ROLE_SCOPES={}
|
| 191 |
MCP_STDIO_API_KEY=<a-valid-mp_live-or-mp_test-key-if-stdio-MCP-is-enabled>
|
| 192 |
```
|
| 193 |
|
| 194 |
+
When social automation is enabled, configure its existing separate tenant and
|
| 195 |
+
worker PostgreSQL URLs/roles as described in the deployment gate. `BASE_URL`
|
| 196 |
+
is the public Hugging Face origin, not the Vercel frontend URL.
|
| 197 |
|
| 198 |
### Vercel environment variables
|
| 199 |
|
|
|
|
| 316 |
| Jobs | `jobs:read`, `jobs:create`, `jobs:cancel` |
|
| 317 |
| Assets | `assets:read`, `assets:write`, `assets:delete` |
|
| 318 |
| MCP | `mcp:read`, `mcp:execute` |
|
| 319 |
+
| Generation foundation | `generation:providers:read`, `generation:requests:read`, `generation:requests:create`, `generation:jobs:cancel` |
|
| 320 |
| System | `system:read` |
|
| 321 |
| Administration | `admin` |
|
| 322 |
|
|
|
|
| 1317 |
|
| 1318 |
| Variable | Default | Purpose |
|
| 1319 |
|---|---:|---|
|
| 1320 |
+
| `APP_NAME` | `MediaRouter` | OpenAPI/application name |
|
| 1321 |
+
| `APP_VERSION` | `1.0.0` | Runtime and health version |
|
| 1322 |
+
| `APP_ENVIRONMENT` | `development` (`production` in Docker) | Enables fail-closed production configuration validation |
|
| 1323 |
+
| `HOST` | `0.0.0.0` | Documented bind host; Docker command binds explicitly to this address |
|
| 1324 |
+
| `PORT` | `7860` | Hugging Face application port |
|
| 1325 |
+
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated exact frontend origins; production requires HTTPS and rejects wildcard CORS |
|
| 1326 |
| `TEMP_DIR` | `./temp` | Request workspaces and in-progress files |
|
| 1327 |
| `OUTPUT_DIR` | `./outputs` | Published files served by download URLs |
|
| 1328 |
| `TEMPLATE_DIR` | built-in `app/templates/categories` | Recursively scanned YAML workflow catalog |
|
|
|
|
| 1340 |
| `FFMPEG_BINARY` | `ffmpeg` | FFmpeg executable name/path |
|
| 1341 |
| `FFPROBE_BINARY` | `ffprobe` | FFprobe executable name/path |
|
| 1342 |
| `AUTH_ENABLED` | `true` | Fail-closed API-key enforcement; disable only for isolated development/tests |
|
| 1343 |
+
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/mediarouter.db` | Backend-only security/tenant/asset store; bare `postgresql://` is normalized to `postgresql+asyncpg://` |
|
| 1344 |
+
| `SECURITY_AUTO_MIGRATE` | `false` | Permit metadata creation for controlled local/testing use; PostgreSQL production must apply `app/security/migrations/` |
|
| 1345 |
+
| `SECURITY_DATABASE_ROLE` | empty | Required with PostgreSQL RLS; backend-only role for authoritative tenancy and canonical asset administration |
|
| 1346 |
+
| `SECURITY_ENFORCE_RLS` | `true` | Fail startup if the security-store role cannot administer its forced-RLS tables |
|
| 1347 |
| `AUTH_ROLE_SCOPES` | `{}` | JSON custom role-to-scope mappings |
|
| 1348 |
| `AUTH_BOOTSTRAP_KEY_HASH` | empty | SHA-256 hash for first-start administrator |
|
| 1349 |
| `AUTH_BOOTSTRAP_KEY_PREFIX` | empty | Safe display prefix matching the bootstrap key |
|
|
|
|
| 1356 |
| `AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY` | `107374182400` | Default daily uploaded processing bytes |
|
| 1357 |
| `AUTH_TRUST_PROXY_HEADERS` | `true` | Use first `X-Forwarded-For` address for audits behind HF/Vercel |
|
| 1358 |
| `MCP_STDIO_API_KEY` | empty | Existing API key required by authenticated standalone stdio MCP |
|
| 1359 |
+
| `GENERATION_ENABLED` | `true` | Enable the additive durable generation domain; it does not configure a model by itself |
|
| 1360 |
+
| `GENERATION_JOB_RETRY_LIMIT` | `3` | Bounded durable submission retry count; the initial submission is separate |
|
| 1361 |
+
| `AI_WORKER_CONNECT_TIMEOUT_SECONDS` | `10` | Remote generation-worker connection timeout |
|
| 1362 |
+
| `AI_WORKER_REQUEST_TIMEOUT_SECONDS` | `60` | Remote generation-worker request/write timeout |
|
| 1363 |
+
| `AI_WORKER_READ_TIMEOUT_SECONDS` | `300` | Remote generation-worker response/read timeout |
|
| 1364 |
+
| `AI_WORKER_MAX_RETRIES` | `3` | Bounded transport retries for safe idempotent worker operations |
|
| 1365 |
+
| `AI_WORKER_RETRY_BACKOFF_SECONDS` | `0.5` | Base exponential backoff for worker transport retries |
|
| 1366 |
+
| `WAN_SPACE_URL` | empty | Trusted backend-only WAN worker origin; requires `WAN_SPACE_TOKEN` and is never client-selectable |
|
| 1367 |
+
| `WAN_SPACE_TOKEN` | empty | Backend-only Bearer token for the WAN worker; never return, log, or store in browser/MCP/n8n/SDK output |
|
| 1368 |
+
| `FLUX_SPACE_URL` | empty | Trusted backend-only FLUX worker origin; requires `FLUX_SPACE_TOKEN` and is never client-selectable |
|
| 1369 |
+
| `FLUX_SPACE_TOKEN` | empty | Backend-only Bearer token for FLUX; never return, log, or store in browser/MCP/n8n/SDK output |
|
| 1370 |
+
| `GENERATION_WORKER_ENABLED` | `true` | Run durable generation dispatch, polling, and output-ingestion worker |
|
| 1371 |
+
| `GENERATION_WORKER_INTERVAL_SECONDS` | `5` | Generation worker dispatch/reconciliation interval |
|
| 1372 |
+
| `GENERATION_WORKER_POLL_BACKOFF_SECONDS` | `2` | Base bounded reconciliation poll backoff after a transient worker error |
|
| 1373 |
+
| `GENERATION_WORKER_BATCH_SIZE` | `8` | Maximum generation jobs claimed per dispatch/reconciliation cycle |
|
| 1374 |
+
| `GENERATION_JOB_STALE_AFTER_SECONDS` | `900` | Submission ambiguity threshold and durable reconciliation lease duration |
|
| 1375 |
| `SOCIAL_ENABLED` | `true` | Enable the additive social domain; existing media APIs remain independent |
|
| 1376 |
| `SOCIAL_DATABASE_URL` | `DATABASE_URL` | Async SQLAlchemy URL; use Supabase/Postgres in production |
|
| 1377 |
+
| `SOCIAL_TENANT_DATABASE_ROLE` | empty | Required with PostgreSQL RLS; non-owner/non-`BYPASSRLS` role for API tenant sessions |
|
| 1378 |
+
| `SOCIAL_WORKER_DATABASE_URL` | empty | Backend-only trusted worker connection; required for PostgreSQL scheduler/Vault usage |
|
| 1379 |
+
| `SOCIAL_WORKER_DATABASE_ROLE` | empty | Expected `BYPASSRLS` role for the worker URL; checked at social startup |
|
| 1380 |
+
| `SOCIAL_ENFORCE_RLS` | `true` | Fail startup if PostgreSQL API/worker role separation cannot be verified |
|
| 1381 |
| `SOCIAL_AUTO_MIGRATE` | `false` | Local/test metadata creation only; never use for production migration management |
|
| 1382 |
| `SOCIAL_WORKER_ENABLED` | `true` | Run durable scheduler/publisher claim loop when schema is ready |
|
| 1383 |
| `SOCIAL_SCHEDULER_INTERVAL_SECONDS` | `30` | Scheduler polling interval |
|
app/ai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Provider-neutral AI Studio application boundary."""
|
app/ai/api.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Header, Path, Query, Request, status
|
| 6 |
+
|
| 7 |
+
from app.ai.schemas import AiCapabilities, AiGenerationRequest, AiHistory, AiJob
|
| 8 |
+
from app.security.errors import ForbiddenError
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/v1/ai", tags=["ai"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _identity(request: Request) -> tuple[str, str, str | None, str]:
|
| 14 |
+
context = request.state.auth
|
| 15 |
+
if not context.workspace_id or not context.user_id:
|
| 16 |
+
raise ForbiddenError
|
| 17 |
+
return (
|
| 18 |
+
context.workspace_id,
|
| 19 |
+
context.user_id,
|
| 20 |
+
context.api_key_id,
|
| 21 |
+
request.state.request_id,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/capabilities", response_model=AiCapabilities)
|
| 26 |
+
async def capabilities(request: Request) -> AiCapabilities:
|
| 27 |
+
return request.app.state.container.ai.capabilities()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/jobs", response_model=AiHistory)
|
| 31 |
+
async def list_jobs(
|
| 32 |
+
request: Request,
|
| 33 |
+
offset: Annotated[int, Query(ge=0)] = 0,
|
| 34 |
+
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
| 35 |
+
) -> AiHistory:
|
| 36 |
+
workspace_id, user_id, _, _ = _identity(request)
|
| 37 |
+
return await request.app.state.container.ai.history(
|
| 38 |
+
workspace_id=workspace_id,
|
| 39 |
+
user_id=user_id,
|
| 40 |
+
offset=offset,
|
| 41 |
+
limit=limit,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/jobs", response_model=AiJob, status_code=status.HTTP_202_ACCEPTED)
|
| 46 |
+
async def create_job(
|
| 47 |
+
request: Request,
|
| 48 |
+
payload: AiGenerationRequest,
|
| 49 |
+
idempotency_key: Annotated[str, Header(alias="Idempotency-Key", min_length=8, max_length=255)],
|
| 50 |
+
) -> AiJob:
|
| 51 |
+
workspace_id, user_id, api_key_id, request_id = _identity(request)
|
| 52 |
+
return await request.app.state.container.ai.create(
|
| 53 |
+
workspace_id=workspace_id,
|
| 54 |
+
user_id=user_id,
|
| 55 |
+
api_key_id=api_key_id,
|
| 56 |
+
request_id=request_id,
|
| 57 |
+
payload=payload,
|
| 58 |
+
idempotency_key=idempotency_key,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.get("/jobs/{generation_id}", response_model=AiJob)
|
| 63 |
+
async def get_job(
|
| 64 |
+
request: Request,
|
| 65 |
+
generation_id: Annotated[str, Path(min_length=36, max_length=36)],
|
| 66 |
+
) -> AiJob:
|
| 67 |
+
workspace_id, user_id, _, _ = _identity(request)
|
| 68 |
+
return await request.app.state.container.ai.get(
|
| 69 |
+
workspace_id=workspace_id,
|
| 70 |
+
user_id=user_id,
|
| 71 |
+
generation_id=generation_id,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.post("/jobs/{generation_id}/cancel", response_model=AiJob)
|
| 76 |
+
async def cancel_job(
|
| 77 |
+
request: Request,
|
| 78 |
+
generation_id: Annotated[str, Path(min_length=36, max_length=36)],
|
| 79 |
+
) -> AiJob:
|
| 80 |
+
workspace_id, user_id, api_key_id, request_id = _identity(request)
|
| 81 |
+
return await request.app.state.container.ai.cancel(
|
| 82 |
+
workspace_id=workspace_id,
|
| 83 |
+
user_id=user_id,
|
| 84 |
+
api_key_id=api_key_id,
|
| 85 |
+
request_id=request_id,
|
| 86 |
+
generation_id=generation_id,
|
| 87 |
+
)
|
app/ai/schemas.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Annotated, Literal
|
| 5 |
+
from uuid import UUID
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
AiCategory = Literal["generate", "transform", "understand", "create"]
|
| 11 |
+
AiOperation = Literal["generate_image", "generate_video"]
|
| 12 |
+
AiJobStatus = Literal[
|
| 13 |
+
"queued", "processing", "retrying", "completed", "failed", "cancelling", "cancelled"
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AiModel(BaseModel):
|
| 18 |
+
model_config = ConfigDict(extra="forbid")
|
| 19 |
+
|
| 20 |
+
id: str
|
| 21 |
+
display_name: str
|
| 22 |
+
operation: AiOperation
|
| 23 |
+
input_types: list[str]
|
| 24 |
+
output_types: list[str]
|
| 25 |
+
input_schema: dict[str, object] = Field(default_factory=dict)
|
| 26 |
+
available: bool
|
| 27 |
+
provider_display_name: str
|
| 28 |
+
pricing: None = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AiTool(BaseModel):
|
| 32 |
+
model_config = ConfigDict(extra="forbid")
|
| 33 |
+
|
| 34 |
+
id: str
|
| 35 |
+
category: AiCategory
|
| 36 |
+
operation: AiOperation
|
| 37 |
+
name: str
|
| 38 |
+
description: str
|
| 39 |
+
input_types: list[str]
|
| 40 |
+
output_types: list[str]
|
| 41 |
+
required_permission: str
|
| 42 |
+
job_type: str = "generation"
|
| 43 |
+
supports_project_context: bool = True
|
| 44 |
+
supports_asset_input: bool
|
| 45 |
+
supports_prompt_input: bool = True
|
| 46 |
+
supports_multiple_inputs: bool = False
|
| 47 |
+
supports_batch: bool = False
|
| 48 |
+
available: bool
|
| 49 |
+
models: list[AiModel] = Field(default_factory=list)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AiProvider(BaseModel):
|
| 53 |
+
model_config = ConfigDict(extra="forbid")
|
| 54 |
+
|
| 55 |
+
id: str
|
| 56 |
+
display_name: str
|
| 57 |
+
available: bool
|
| 58 |
+
operations: list[AiOperation] = Field(default_factory=list)
|
| 59 |
+
supports_cancellation: bool = False
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class AiCapabilities(BaseModel):
|
| 63 |
+
model_config = ConfigDict(extra="forbid")
|
| 64 |
+
|
| 65 |
+
available: bool
|
| 66 |
+
categories: list[AiCategory]
|
| 67 |
+
tools: list[AiTool]
|
| 68 |
+
providers: list[AiProvider]
|
| 69 |
+
permissions: list[str]
|
| 70 |
+
feature_flag: str = "ai"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class AiOutputPreferences(BaseModel):
|
| 74 |
+
model_config = ConfigDict(extra="forbid")
|
| 75 |
+
|
| 76 |
+
attach_to_project: bool = True
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class AiImageParameters(BaseModel):
|
| 80 |
+
model_config = ConfigDict(extra="forbid")
|
| 81 |
+
|
| 82 |
+
mode: Literal["fast", "quality"] = "fast"
|
| 83 |
+
seed: int = Field(default=42, ge=0, le=2_147_483_647)
|
| 84 |
+
randomize_seed: bool = False
|
| 85 |
+
width: int = Field(default=1024, ge=256, le=1024, multiple_of=8)
|
| 86 |
+
height: int = Field(default=1024, ge=256, le=1024, multiple_of=8)
|
| 87 |
+
steps: int = Field(default=4, ge=1, le=100)
|
| 88 |
+
guidance: float = Field(default=1.0, ge=0.0, le=10.0)
|
| 89 |
+
enhance_prompt: bool = False
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class AiVideoParameters(BaseModel):
|
| 93 |
+
model_config = ConfigDict(extra="forbid")
|
| 94 |
+
|
| 95 |
+
negative_prompt: str | None = Field(default=None, max_length=4_000)
|
| 96 |
+
duration_seconds: float = Field(default=5.0, ge=0.5, le=5.0)
|
| 97 |
+
steps: int = Field(default=4, ge=1, le=30)
|
| 98 |
+
guidance: float = Field(default=1.0, ge=0.0, le=10.0)
|
| 99 |
+
secondary_guidance: float = Field(default=1.0, ge=0.0, le=10.0)
|
| 100 |
+
seed: int = Field(default=42, ge=0, le=2_147_483_647)
|
| 101 |
+
randomize_seed: bool = False
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class AiGenerateImageRequest(BaseModel):
|
| 105 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 106 |
+
|
| 107 |
+
operation: Literal["generate_image"]
|
| 108 |
+
prompt: str = Field(min_length=1, max_length=4_000)
|
| 109 |
+
model: str | None = Field(default=None, min_length=1, max_length=255)
|
| 110 |
+
project_id: UUID | None = None
|
| 111 |
+
source_asset_ids: list[UUID] = Field(default_factory=list, max_length=1)
|
| 112 |
+
parameters: AiImageParameters = Field(default_factory=AiImageParameters)
|
| 113 |
+
output_preferences: AiOutputPreferences = Field(default_factory=AiOutputPreferences)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class AiGenerateVideoRequest(BaseModel):
|
| 117 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 118 |
+
|
| 119 |
+
operation: Literal["generate_video"]
|
| 120 |
+
prompt: str = Field(min_length=1, max_length=4_000)
|
| 121 |
+
model: str | None = Field(default=None, min_length=1, max_length=255)
|
| 122 |
+
project_id: UUID | None = None
|
| 123 |
+
source_asset_ids: list[UUID] = Field(min_length=1, max_length=1)
|
| 124 |
+
parameters: AiVideoParameters = Field(default_factory=AiVideoParameters)
|
| 125 |
+
output_preferences: AiOutputPreferences = Field(default_factory=AiOutputPreferences)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
AiGenerationRequest = Annotated[
|
| 129 |
+
AiGenerateImageRequest | AiGenerateVideoRequest,
|
| 130 |
+
Field(discriminator="operation"),
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class AiOutput(BaseModel):
|
| 135 |
+
model_config = ConfigDict(extra="forbid")
|
| 136 |
+
|
| 137 |
+
asset_id: str
|
| 138 |
+
media_type: Literal["image", "video"]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class AiJob(BaseModel):
|
| 142 |
+
model_config = ConfigDict(extra="forbid")
|
| 143 |
+
|
| 144 |
+
id: str
|
| 145 |
+
generation_id: str
|
| 146 |
+
operation: AiOperation
|
| 147 |
+
status: AiJobStatus
|
| 148 |
+
prompt: str
|
| 149 |
+
project_id: str | None = None
|
| 150 |
+
source_asset_id: str | None = None
|
| 151 |
+
output: AiOutput | None = None
|
| 152 |
+
model: str
|
| 153 |
+
provider_display_name: str
|
| 154 |
+
progress: None = None
|
| 155 |
+
error_code: str | None = None
|
| 156 |
+
error_message: str | None = None
|
| 157 |
+
retryable: bool = False
|
| 158 |
+
usage: None = None
|
| 159 |
+
estimated_cost: None = None
|
| 160 |
+
actual_cost: None = None
|
| 161 |
+
created_at: datetime
|
| 162 |
+
updated_at: datetime
|
| 163 |
+
completed_at: datetime | None = None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class AiHistory(BaseModel):
|
| 167 |
+
model_config = ConfigDict(extra="forbid")
|
| 168 |
+
|
| 169 |
+
items: list[AiJob]
|
| 170 |
+
offset: int
|
| 171 |
+
limit: int
|
app/ai/service.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.ai.schemas import (
|
| 4 |
+
AiCapabilities,
|
| 5 |
+
AiGenerateImageRequest,
|
| 6 |
+
AiGenerateVideoRequest,
|
| 7 |
+
AiGenerationRequest,
|
| 8 |
+
AiHistory,
|
| 9 |
+
AiJob,
|
| 10 |
+
AiModel,
|
| 11 |
+
AiOutput,
|
| 12 |
+
AiProvider,
|
| 13 |
+
AiTool,
|
| 14 |
+
)
|
| 15 |
+
from app.generation.domain.enums import (
|
| 16 |
+
GenerationModality,
|
| 17 |
+
GenerationRequestStatus,
|
| 18 |
+
)
|
| 19 |
+
from app.generation.domain.errors import (
|
| 20 |
+
GenerationCapabilityUnsupportedError,
|
| 21 |
+
GenerationValidationError,
|
| 22 |
+
)
|
| 23 |
+
from app.generation.model_registry import GenerationModelView
|
| 24 |
+
from app.generation.schemas.requests import (
|
| 25 |
+
FluxGenerationOptions,
|
| 26 |
+
GenerationRequestCreate,
|
| 27 |
+
GenerationRequestView,
|
| 28 |
+
WanGenerationOptions,
|
| 29 |
+
)
|
| 30 |
+
from app.generation.services.generation_service import GenerationService
|
| 31 |
+
from app.projects.schemas import ProjectStatus
|
| 32 |
+
from app.projects.services.project_service import ProjectService
|
| 33 |
+
from app.security.assets import CanonicalAssetNotFoundError, CanonicalAssetService
|
| 34 |
+
from app.security.audit import AuditService
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_OPERATION_MODALITY = {
|
| 38 |
+
"generate_image": GenerationModality.IMAGE,
|
| 39 |
+
"generate_video": GenerationModality.VIDEO,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class AiStudioService:
|
| 44 |
+
"""Capability-driven facade over the existing durable generation domain."""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
generation: GenerationService,
|
| 49 |
+
projects: ProjectService,
|
| 50 |
+
assets: CanonicalAssetService,
|
| 51 |
+
audit: AuditService,
|
| 52 |
+
) -> None:
|
| 53 |
+
self.generation = generation
|
| 54 |
+
self.projects = projects
|
| 55 |
+
self.assets = assets
|
| 56 |
+
self.audit = audit
|
| 57 |
+
|
| 58 |
+
def capabilities(self) -> AiCapabilities:
|
| 59 |
+
models = self.generation.models.list()
|
| 60 |
+
tools = [
|
| 61 |
+
self._tool(
|
| 62 |
+
operation="generate_image",
|
| 63 |
+
name="Generate Image",
|
| 64 |
+
description="Create or transform a canonical image with an available image model.",
|
| 65 |
+
models=[item for item in models if item.model.modality is GenerationModality.IMAGE],
|
| 66 |
+
input_types=["text", "image"],
|
| 67 |
+
output_types=["image"],
|
| 68 |
+
),
|
| 69 |
+
self._tool(
|
| 70 |
+
operation="generate_video",
|
| 71 |
+
name="Generate Video",
|
| 72 |
+
description="Create a video from a canonical project image.",
|
| 73 |
+
models=[item for item in models if item.model.modality is GenerationModality.VIDEO],
|
| 74 |
+
input_types=["text", "image"],
|
| 75 |
+
output_types=["video"],
|
| 76 |
+
),
|
| 77 |
+
]
|
| 78 |
+
providers = []
|
| 79 |
+
for adapter in self.generation.providers.list():
|
| 80 |
+
provider_models = [item for item in models if item.provider_id == adapter.provider]
|
| 81 |
+
operations = sorted({self._operation_for_model(item) for item in provider_models})
|
| 82 |
+
providers.append(
|
| 83 |
+
AiProvider(
|
| 84 |
+
id=adapter.provider,
|
| 85 |
+
display_name=adapter.capabilities.name,
|
| 86 |
+
available=bool(
|
| 87 |
+
self.generation.ready
|
| 88 |
+
and adapter.available
|
| 89 |
+
and any(item.available for item in provider_models)
|
| 90 |
+
),
|
| 91 |
+
operations=operations,
|
| 92 |
+
supports_cancellation=adapter.capabilities.supports_cancellation,
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
return AiCapabilities(
|
| 96 |
+
available=any(tool.available for tool in tools),
|
| 97 |
+
categories=["generate"],
|
| 98 |
+
tools=tools,
|
| 99 |
+
providers=providers,
|
| 100 |
+
permissions=[
|
| 101 |
+
"ai:read",
|
| 102 |
+
"ai:generate",
|
| 103 |
+
"ai:transform",
|
| 104 |
+
"ai:analyze",
|
| 105 |
+
"ai:create",
|
| 106 |
+
],
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
async def create(
|
| 110 |
+
self,
|
| 111 |
+
*,
|
| 112 |
+
workspace_id: str,
|
| 113 |
+
user_id: str,
|
| 114 |
+
api_key_id: str | None,
|
| 115 |
+
request_id: str,
|
| 116 |
+
payload: AiGenerationRequest,
|
| 117 |
+
idempotency_key: str,
|
| 118 |
+
) -> AiJob:
|
| 119 |
+
project_id = str(payload.project_id) if payload.project_id else None
|
| 120 |
+
source_asset_id = str(payload.source_asset_ids[0]) if payload.source_asset_ids else None
|
| 121 |
+
if payload.project_id is not None:
|
| 122 |
+
project = await self.projects.get(
|
| 123 |
+
workspace_id=workspace_id,
|
| 124 |
+
user_id=user_id,
|
| 125 |
+
project_id=project_id or "",
|
| 126 |
+
)
|
| 127 |
+
if project.status is not ProjectStatus.ACTIVE:
|
| 128 |
+
raise GenerationValidationError("AI jobs require an active project.")
|
| 129 |
+
if source_asset_id is not None:
|
| 130 |
+
try:
|
| 131 |
+
asset = await self.assets.get_owned_by_id(
|
| 132 |
+
workspace_id=workspace_id,
|
| 133 |
+
user_id=user_id,
|
| 134 |
+
asset_id=source_asset_id,
|
| 135 |
+
)
|
| 136 |
+
except CanonicalAssetNotFoundError as exc:
|
| 137 |
+
raise GenerationValidationError(
|
| 138 |
+
"AI source asset was not found in this workspace."
|
| 139 |
+
) from exc
|
| 140 |
+
if project_id is not None and asset.project_id != project_id:
|
| 141 |
+
raise GenerationValidationError(
|
| 142 |
+
"AI source asset must belong to the selected project."
|
| 143 |
+
)
|
| 144 |
+
model = self._select_model(payload.operation, payload.model)
|
| 145 |
+
request = self._generation_request(payload, model)
|
| 146 |
+
created = await self.generation.create(
|
| 147 |
+
workspace_id=workspace_id,
|
| 148 |
+
user_id=user_id,
|
| 149 |
+
payload=request,
|
| 150 |
+
idempotency_key=idempotency_key,
|
| 151 |
+
project_id=(project_id if payload.output_preferences.attach_to_project else None),
|
| 152 |
+
product_surface="ai_studio",
|
| 153 |
+
)
|
| 154 |
+
await self.audit.record_event(
|
| 155 |
+
workspace_id=workspace_id,
|
| 156 |
+
user_id=user_id,
|
| 157 |
+
api_key_id=api_key_id,
|
| 158 |
+
request_id=request_id,
|
| 159 |
+
event_type="ai.generation_requested",
|
| 160 |
+
entity_type="generation_job",
|
| 161 |
+
entity_id=created.job.id,
|
| 162 |
+
metadata={
|
| 163 |
+
"operation": payload.operation,
|
| 164 |
+
"model": created.model_id,
|
| 165 |
+
"project_id": project_id,
|
| 166 |
+
},
|
| 167 |
+
)
|
| 168 |
+
return self._job(created)
|
| 169 |
+
|
| 170 |
+
async def history(
|
| 171 |
+
self,
|
| 172 |
+
*,
|
| 173 |
+
workspace_id: str,
|
| 174 |
+
user_id: str,
|
| 175 |
+
offset: int,
|
| 176 |
+
limit: int,
|
| 177 |
+
) -> AiHistory:
|
| 178 |
+
items = await self.generation.list_requests(
|
| 179 |
+
workspace_id,
|
| 180 |
+
user_id,
|
| 181 |
+
offset=offset,
|
| 182 |
+
limit=limit,
|
| 183 |
+
product_surface="ai_studio",
|
| 184 |
+
)
|
| 185 |
+
return AiHistory(
|
| 186 |
+
items=[self._job(item) for item in items],
|
| 187 |
+
offset=offset,
|
| 188 |
+
limit=limit,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
async def get(self, *, workspace_id: str, user_id: str, generation_id: str) -> AiJob:
|
| 192 |
+
request = await self.generation.get_request(workspace_id, user_id, generation_id)
|
| 193 |
+
if request.product_surface != "ai_studio":
|
| 194 |
+
raise GenerationValidationError("AI generation was not found.")
|
| 195 |
+
return self._job(request)
|
| 196 |
+
|
| 197 |
+
async def cancel(
|
| 198 |
+
self,
|
| 199 |
+
*,
|
| 200 |
+
workspace_id: str,
|
| 201 |
+
user_id: str,
|
| 202 |
+
api_key_id: str | None,
|
| 203 |
+
request_id: str,
|
| 204 |
+
generation_id: str,
|
| 205 |
+
) -> AiJob:
|
| 206 |
+
request = await self.generation.get_request(workspace_id, user_id, generation_id)
|
| 207 |
+
if request.product_surface != "ai_studio":
|
| 208 |
+
raise GenerationValidationError("AI generation was not found.")
|
| 209 |
+
await self.generation.cancel(workspace_id, user_id, request.job.id)
|
| 210 |
+
updated = await self.generation.get_request(workspace_id, user_id, generation_id)
|
| 211 |
+
if updated.status is GenerationRequestStatus.CANCELLED:
|
| 212 |
+
await self.audit.record_event(
|
| 213 |
+
workspace_id=workspace_id,
|
| 214 |
+
user_id=user_id,
|
| 215 |
+
api_key_id=api_key_id,
|
| 216 |
+
request_id=request_id,
|
| 217 |
+
event_type="ai.generation_cancelled",
|
| 218 |
+
entity_type="generation_job",
|
| 219 |
+
entity_id=request.job.id,
|
| 220 |
+
metadata={"operation": self._operation_for_request(request)},
|
| 221 |
+
)
|
| 222 |
+
return self._job(updated)
|
| 223 |
+
|
| 224 |
+
def _select_model(self, operation: str, requested_model: str | None) -> GenerationModelView:
|
| 225 |
+
modality = _OPERATION_MODALITY[operation]
|
| 226 |
+
matches = [
|
| 227 |
+
model
|
| 228 |
+
for model in self.generation.models.list()
|
| 229 |
+
if model.model.modality is modality
|
| 230 |
+
and model.available
|
| 231 |
+
and (requested_model is None or model.model.id == requested_model)
|
| 232 |
+
]
|
| 233 |
+
if not matches:
|
| 234 |
+
raise GenerationCapabilityUnsupportedError(
|
| 235 |
+
"No ready model supports the requested AI operation."
|
| 236 |
+
)
|
| 237 |
+
if requested_model is not None and len(matches) != 1:
|
| 238 |
+
raise GenerationCapabilityUnsupportedError("The requested AI model is not available.")
|
| 239 |
+
return sorted(matches, key=lambda item: (item.provider_id, item.model.id))[0]
|
| 240 |
+
|
| 241 |
+
@staticmethod
|
| 242 |
+
def _generation_request(
|
| 243 |
+
payload: AiGenerationRequest, model: GenerationModelView
|
| 244 |
+
) -> GenerationRequestCreate:
|
| 245 |
+
source_asset_id = str(payload.source_asset_ids[0]) if payload.source_asset_ids else None
|
| 246 |
+
if isinstance(payload, AiGenerateImageRequest):
|
| 247 |
+
options = payload.parameters
|
| 248 |
+
return GenerationRequestCreate(
|
| 249 |
+
provider=model.provider_id,
|
| 250 |
+
model_id=model.model.id,
|
| 251 |
+
modality=GenerationModality.IMAGE,
|
| 252 |
+
prompt=payload.prompt,
|
| 253 |
+
input_asset_id=source_asset_id,
|
| 254 |
+
flux=FluxGenerationOptions(
|
| 255 |
+
mode_choice=(
|
| 256 |
+
"Base (50 steps)" if options.mode == "quality" else "Distilled (4 steps)"
|
| 257 |
+
),
|
| 258 |
+
seed=options.seed,
|
| 259 |
+
randomize_seed=options.randomize_seed,
|
| 260 |
+
width=options.width,
|
| 261 |
+
height=options.height,
|
| 262 |
+
num_inference_steps=options.steps,
|
| 263 |
+
guidance_scale=options.guidance,
|
| 264 |
+
prompt_upsampling=options.enhance_prompt,
|
| 265 |
+
),
|
| 266 |
+
)
|
| 267 |
+
if isinstance(payload, AiGenerateVideoRequest):
|
| 268 |
+
options = payload.parameters
|
| 269 |
+
return GenerationRequestCreate(
|
| 270 |
+
provider=model.provider_id,
|
| 271 |
+
model_id=model.model.id,
|
| 272 |
+
modality=GenerationModality.VIDEO,
|
| 273 |
+
prompt=payload.prompt,
|
| 274 |
+
input_asset_id=source_asset_id,
|
| 275 |
+
wan=WanGenerationOptions(
|
| 276 |
+
negative_prompt=options.negative_prompt,
|
| 277 |
+
duration_seconds=options.duration_seconds,
|
| 278 |
+
steps=options.steps,
|
| 279 |
+
guidance_scale=options.guidance,
|
| 280 |
+
guidance_scale_2=options.secondary_guidance,
|
| 281 |
+
seed=options.seed,
|
| 282 |
+
randomize_seed=options.randomize_seed,
|
| 283 |
+
),
|
| 284 |
+
)
|
| 285 |
+
raise GenerationCapabilityUnsupportedError("AI operation is unsupported.")
|
| 286 |
+
|
| 287 |
+
def _tool(
|
| 288 |
+
self,
|
| 289 |
+
*,
|
| 290 |
+
operation: str,
|
| 291 |
+
name: str,
|
| 292 |
+
description: str,
|
| 293 |
+
models: list[GenerationModelView],
|
| 294 |
+
input_types: list[str],
|
| 295 |
+
output_types: list[str],
|
| 296 |
+
) -> AiTool:
|
| 297 |
+
public_models = [self._model(item, operation) for item in models]
|
| 298 |
+
return AiTool(
|
| 299 |
+
id=operation.replace("_", "-"),
|
| 300 |
+
category="generate",
|
| 301 |
+
operation=operation,
|
| 302 |
+
name=name,
|
| 303 |
+
description=description,
|
| 304 |
+
input_types=input_types,
|
| 305 |
+
output_types=output_types,
|
| 306 |
+
required_permission="ai:generate",
|
| 307 |
+
supports_asset_input=any(item.model.input_asset_supported for item in models),
|
| 308 |
+
available=any(item.available and self.generation.ready for item in models),
|
| 309 |
+
models=public_models,
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
def _model(self, item: GenerationModelView, operation: str) -> AiModel:
|
| 313 |
+
provider = self.generation.providers.get(item.provider_id)
|
| 314 |
+
return AiModel(
|
| 315 |
+
id=item.model.id,
|
| 316 |
+
display_name=item.model.name,
|
| 317 |
+
operation=operation,
|
| 318 |
+
input_types=(["text", "image"] if item.model.input_asset_supported else ["text"]),
|
| 319 |
+
output_types=[item.model.modality.value],
|
| 320 |
+
input_schema=dict(item.model.input_schema),
|
| 321 |
+
available=bool(self.generation.ready and item.available),
|
| 322 |
+
provider_display_name=provider.capabilities.name,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
@staticmethod
|
| 326 |
+
def _operation_for_model(item: GenerationModelView) -> str:
|
| 327 |
+
return (
|
| 328 |
+
"generate_image"
|
| 329 |
+
if item.model.modality is GenerationModality.IMAGE
|
| 330 |
+
else "generate_video"
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
@staticmethod
|
| 334 |
+
def _operation_for_request(item: GenerationRequestView) -> str:
|
| 335 |
+
return "generate_image" if item.modality is GenerationModality.IMAGE else "generate_video"
|
| 336 |
+
|
| 337 |
+
def _job(self, item: GenerationRequestView) -> AiJob:
|
| 338 |
+
operation = self._operation_for_request(item)
|
| 339 |
+
status_map = {
|
| 340 |
+
GenerationRequestStatus.QUEUED: "queued",
|
| 341 |
+
GenerationRequestStatus.SUBMITTING: "processing",
|
| 342 |
+
GenerationRequestStatus.RUNNING: "processing",
|
| 343 |
+
GenerationRequestStatus.RETRYING: "retrying",
|
| 344 |
+
GenerationRequestStatus.SUCCEEDED: "completed",
|
| 345 |
+
GenerationRequestStatus.FAILED: "failed",
|
| 346 |
+
GenerationRequestStatus.CANCEL_REQUESTED: "cancelling",
|
| 347 |
+
GenerationRequestStatus.CANCELLED: "cancelled",
|
| 348 |
+
}
|
| 349 |
+
provider = self.generation.providers.get(item.provider)
|
| 350 |
+
output = None
|
| 351 |
+
if item.job.output_asset_id and item.status is GenerationRequestStatus.SUCCEEDED:
|
| 352 |
+
output = AiOutput(
|
| 353 |
+
asset_id=item.job.output_asset_id,
|
| 354 |
+
media_type=("image" if item.modality is GenerationModality.IMAGE else "video"),
|
| 355 |
+
)
|
| 356 |
+
retryable_codes = {
|
| 357 |
+
"GENERATION_MODEL_UNAVAILABLE",
|
| 358 |
+
"GENERATION_SUBMISSION_RETRYING",
|
| 359 |
+
"GENERATION_PROVIDER_STATUS_FAILED",
|
| 360 |
+
}
|
| 361 |
+
return AiJob(
|
| 362 |
+
id=item.job.id,
|
| 363 |
+
generation_id=item.id,
|
| 364 |
+
operation=operation,
|
| 365 |
+
status=status_map[item.status],
|
| 366 |
+
prompt=item.prompt,
|
| 367 |
+
project_id=item.project_id,
|
| 368 |
+
source_asset_id=item.input_asset_id,
|
| 369 |
+
output=output,
|
| 370 |
+
model=item.model_id,
|
| 371 |
+
provider_display_name=provider.capabilities.name,
|
| 372 |
+
error_code=item.job.error_code,
|
| 373 |
+
error_message=item.job.error_message,
|
| 374 |
+
retryable=item.job.error_code in retryable_codes,
|
| 375 |
+
created_at=item.created_at,
|
| 376 |
+
updated_at=item.updated_at,
|
| 377 |
+
completed_at=item.completed_at,
|
| 378 |
+
)
|
app/analytics/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Workspace analytics domain built on authoritative Social provider data."""
|
app/analytics/api.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, Header, Query, Request, status
|
| 6 |
+
|
| 7 |
+
from app.analytics.schemas import (
|
| 8 |
+
AnalyticsCapabilities,
|
| 9 |
+
AnalyticsOverview,
|
| 10 |
+
AnalyticsPostList,
|
| 11 |
+
AnalyticsQuery,
|
| 12 |
+
AnalyticsSyncList,
|
| 13 |
+
AnalyticsSyncRequest,
|
| 14 |
+
AnalyticsSyncRunView,
|
| 15 |
+
AnalyticsTimeseries,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/v1/analytics", tags=["analytics"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _identity(request: Request) -> tuple[str, str]:
|
| 22 |
+
context = request.state.auth
|
| 23 |
+
if not context.workspace_id or not context.user_id:
|
| 24 |
+
from fastapi import HTTPException
|
| 25 |
+
|
| 26 |
+
raise HTTPException(status_code=403, detail="No active workspace membership.")
|
| 27 |
+
return context.workspace_id, context.user_id
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _service(request: Request):
|
| 31 |
+
service = request.app.state.container.analytics
|
| 32 |
+
service.ensure_ready()
|
| 33 |
+
return service
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/capabilities", response_model=list[AnalyticsCapabilities])
|
| 37 |
+
async def capabilities(request: Request) -> list[AnalyticsCapabilities]:
|
| 38 |
+
return await _service(request).capabilities()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("/overview", response_model=AnalyticsOverview)
|
| 42 |
+
async def overview(
|
| 43 |
+
request: Request, query: Annotated[AnalyticsQuery, Depends()]
|
| 44 |
+
) -> AnalyticsOverview:
|
| 45 |
+
workspace_id, _ = _identity(request)
|
| 46 |
+
return await _service(request).overview(workspace_id, query)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/timeseries", response_model=AnalyticsTimeseries)
|
| 50 |
+
async def timeseries(
|
| 51 |
+
request: Request, query: Annotated[AnalyticsQuery, Depends()]
|
| 52 |
+
) -> AnalyticsTimeseries:
|
| 53 |
+
workspace_id, _ = _identity(request)
|
| 54 |
+
return await _service(request).timeseries(workspace_id, query)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/platforms", response_model=AnalyticsOverview)
|
| 58 |
+
async def platforms(
|
| 59 |
+
request: Request, query: Annotated[AnalyticsQuery, Depends()]
|
| 60 |
+
) -> AnalyticsOverview:
|
| 61 |
+
workspace_id, _ = _identity(request)
|
| 62 |
+
return await _service(request).overview(workspace_id, query)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/platforms/{provider}", response_model=AnalyticsOverview)
|
| 66 |
+
async def platform(
|
| 67 |
+
request: Request,
|
| 68 |
+
provider: str,
|
| 69 |
+
query: Annotated[AnalyticsQuery, Depends()],
|
| 70 |
+
) -> AnalyticsOverview:
|
| 71 |
+
workspace_id, _ = _identity(request)
|
| 72 |
+
query.provider = provider
|
| 73 |
+
return await _service(request).overview(workspace_id, query)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/posts", response_model=AnalyticsPostList)
|
| 77 |
+
async def posts(request: Request, query: Annotated[AnalyticsQuery, Depends()]) -> AnalyticsPostList:
|
| 78 |
+
workspace_id, _ = _identity(request)
|
| 79 |
+
return await _service(request).posts(workspace_id, query)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/posts/{post_id}", response_model=AnalyticsPostList)
|
| 83 |
+
async def post(
|
| 84 |
+
request: Request,
|
| 85 |
+
post_id: str,
|
| 86 |
+
query: Annotated[AnalyticsQuery, Depends()],
|
| 87 |
+
) -> AnalyticsPostList:
|
| 88 |
+
workspace_id, _ = _identity(request)
|
| 89 |
+
return await _service(request).post(workspace_id, post_id, query)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.get("/projects/{project_id}", response_model=AnalyticsOverview)
|
| 93 |
+
async def project(
|
| 94 |
+
request: Request,
|
| 95 |
+
project_id: str,
|
| 96 |
+
query: Annotated[AnalyticsQuery, Depends()],
|
| 97 |
+
) -> AnalyticsOverview:
|
| 98 |
+
workspace_id, user_id = _identity(request)
|
| 99 |
+
await request.app.state.container.projects.get(
|
| 100 |
+
workspace_id=workspace_id, user_id=user_id, project_id=project_id
|
| 101 |
+
)
|
| 102 |
+
query.project_id = project_id
|
| 103 |
+
return await _service(request).overview(workspace_id, query)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.get("/sync-runs", response_model=AnalyticsSyncList)
|
| 107 |
+
async def sync_runs(
|
| 108 |
+
request: Request,
|
| 109 |
+
offset: int = Query(default=0, ge=0, le=100_000),
|
| 110 |
+
limit: int = Query(default=50, ge=1, le=500),
|
| 111 |
+
) -> AnalyticsSyncList:
|
| 112 |
+
workspace_id, _ = _identity(request)
|
| 113 |
+
items = await _service(request).repository.list_syncs(workspace_id, offset=offset, limit=limit)
|
| 114 |
+
return AnalyticsSyncList(
|
| 115 |
+
items=[_service(request)._sync_view(item) for item in items],
|
| 116 |
+
offset=offset,
|
| 117 |
+
limit=limit,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.get("/sync-runs/{run_id}", response_model=AnalyticsSyncRunView)
|
| 122 |
+
async def sync_run(request: Request, run_id: str) -> AnalyticsSyncRunView:
|
| 123 |
+
workspace_id, _ = _identity(request)
|
| 124 |
+
return _service(request)._sync_view(
|
| 125 |
+
await _service(request).repository.get_sync(workspace_id, run_id)
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/sync-runs/{run_id}/cancel", response_model=AnalyticsSyncRunView)
|
| 130 |
+
async def cancel_sync(request: Request, run_id: str) -> AnalyticsSyncRunView:
|
| 131 |
+
workspace_id, _ = _identity(request)
|
| 132 |
+
run = await _service(request).repository.cancel_sync(workspace_id, run_id)
|
| 133 |
+
await _service(request).audit.record(
|
| 134 |
+
workspace_id=workspace_id,
|
| 135 |
+
event_type="analytics.sync_cancelled",
|
| 136 |
+
metadata={"sync_run_id": run.id},
|
| 137 |
+
api_key_id=request.state.auth.api_key_id,
|
| 138 |
+
request_id=request.state.request_id,
|
| 139 |
+
)
|
| 140 |
+
return _service(request)._sync_view(run)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.post("/sync", response_model=AnalyticsSyncRunView, status_code=status.HTTP_202_ACCEPTED)
|
| 144 |
+
async def sync(
|
| 145 |
+
request: Request,
|
| 146 |
+
payload: AnalyticsSyncRequest,
|
| 147 |
+
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
| 148 |
+
) -> AnalyticsSyncRunView:
|
| 149 |
+
workspace_id, user_id = _identity(request)
|
| 150 |
+
if idempotency_key:
|
| 151 |
+
payload.idempotency_key = idempotency_key
|
| 152 |
+
return await _service(request).create_sync(workspace_id, user_id, payload)
|
app/analytics/errors.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.core.exceptions import MediaAPIError
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AnalyticsValidationError(MediaAPIError):
|
| 7 |
+
code = "ANALYTICS_VALIDATION_ERROR"
|
| 8 |
+
status_code = 422
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AnalyticsNotFoundError(MediaAPIError):
|
| 12 |
+
code = "ANALYTICS_NOT_FOUND"
|
| 13 |
+
status_code = 404
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class AnalyticsIdempotencyConflictError(MediaAPIError):
|
| 17 |
+
code = "ANALYTICS_IDEMPOTENCY_CONFLICT"
|
| 18 |
+
status_code = 409
|
app/analytics/models.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import (
|
| 7 |
+
JSON,
|
| 8 |
+
DateTime,
|
| 9 |
+
Float,
|
| 10 |
+
ForeignKey,
|
| 11 |
+
Index,
|
| 12 |
+
Integer,
|
| 13 |
+
String,
|
| 14 |
+
Text,
|
| 15 |
+
UniqueConstraint,
|
| 16 |
+
)
|
| 17 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 18 |
+
|
| 19 |
+
from app.social.models import SocialBase
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def utcnow() -> datetime:
|
| 23 |
+
return datetime.now(timezone.utc)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def new_id() -> str:
|
| 27 |
+
return str(uuid4())
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AnalyticsSyncRun(SocialBase):
|
| 31 |
+
__tablename__ = "analytics_sync_runs"
|
| 32 |
+
__table_args__ = (
|
| 33 |
+
UniqueConstraint(
|
| 34 |
+
"workspace_id", "idempotency_key", name="uq_analytics_sync_workspace_idempotency"
|
| 35 |
+
),
|
| 36 |
+
Index("ix_analytics_sync_workspace_status", "workspace_id", "status"),
|
| 37 |
+
Index("ix_analytics_sync_due", "status", "next_attempt_at"),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
| 41 |
+
workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 42 |
+
project_id: Mapped[str | None] = mapped_column(String(36))
|
| 43 |
+
provider: Mapped[str | None] = mapped_column(String(32))
|
| 44 |
+
date_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 45 |
+
date_to: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 46 |
+
timezone: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 47 |
+
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
| 48 |
+
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 49 |
+
requested_by: Mapped[str | None] = mapped_column(String(120))
|
| 50 |
+
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 51 |
+
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 52 |
+
error_code: Mapped[str | None] = mapped_column(String(100))
|
| 53 |
+
error_message: Mapped[str | None] = mapped_column(Text)
|
| 54 |
+
metrics_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 55 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 56 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 57 |
+
)
|
| 58 |
+
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 59 |
+
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 60 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 61 |
+
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class AnalyticsMetricSnapshot(SocialBase):
|
| 66 |
+
__tablename__ = "analytics_metric_snapshots"
|
| 67 |
+
__table_args__ = (
|
| 68 |
+
UniqueConstraint(
|
| 69 |
+
"workspace_id",
|
| 70 |
+
"provider",
|
| 71 |
+
"external_object_id",
|
| 72 |
+
"metric_name",
|
| 73 |
+
"bucket_start",
|
| 74 |
+
name="uq_analytics_metric_snapshot",
|
| 75 |
+
),
|
| 76 |
+
Index("ix_analytics_metric_snapshots_workspace_bucket", "workspace_id", "bucket_start"),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
| 80 |
+
workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 81 |
+
project_id: Mapped[str | None] = mapped_column(String(36))
|
| 82 |
+
social_post_id: Mapped[str | None] = mapped_column(
|
| 83 |
+
String(36), ForeignKey("social_posts.id", ondelete="CASCADE")
|
| 84 |
+
)
|
| 85 |
+
social_account_id: Mapped[str | None] = mapped_column(
|
| 86 |
+
String(36), ForeignKey("social_accounts.id", ondelete="CASCADE")
|
| 87 |
+
)
|
| 88 |
+
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 89 |
+
external_object_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 90 |
+
metric_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 91 |
+
metric_value: Mapped[float] = mapped_column(Float, nullable=False)
|
| 92 |
+
bucket_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 93 |
+
dimensions: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
|
| 94 |
+
source: Mapped[str] = mapped_column(String(64), nullable=False, default="provider")
|
| 95 |
+
collected_at: Mapped[datetime] = mapped_column(
|
| 96 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 97 |
+
)
|
| 98 |
+
provider_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class AnalyticsPostMetric(SocialBase):
|
| 102 |
+
__tablename__ = "analytics_post_metrics"
|
| 103 |
+
__table_args__ = (
|
| 104 |
+
UniqueConstraint(
|
| 105 |
+
"workspace_id",
|
| 106 |
+
"social_post_target_id",
|
| 107 |
+
"metric_date",
|
| 108 |
+
name="uq_analytics_post_metric_bucket",
|
| 109 |
+
),
|
| 110 |
+
Index("ix_analytics_post_metrics_workspace_date", "workspace_id", "metric_date"),
|
| 111 |
+
Index(
|
| 112 |
+
"ix_analytics_post_metrics_project_date", "workspace_id", "project_id", "metric_date"
|
| 113 |
+
),
|
| 114 |
+
Index("ix_analytics_post_metrics_provider_date", "workspace_id", "provider", "metric_date"),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
| 118 |
+
workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 119 |
+
project_id: Mapped[str | None] = mapped_column(String(36))
|
| 120 |
+
social_post_id: Mapped[str] = mapped_column(
|
| 121 |
+
String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False
|
| 122 |
+
)
|
| 123 |
+
social_post_target_id: Mapped[str] = mapped_column(
|
| 124 |
+
String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE"), nullable=False
|
| 125 |
+
)
|
| 126 |
+
social_account_id: Mapped[str] = mapped_column(
|
| 127 |
+
String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False
|
| 128 |
+
)
|
| 129 |
+
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 130 |
+
external_post_id: Mapped[str | None] = mapped_column(String(255))
|
| 131 |
+
metric_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 132 |
+
views: Mapped[int | None] = mapped_column()
|
| 133 |
+
impressions: Mapped[int | None] = mapped_column()
|
| 134 |
+
likes: Mapped[int | None] = mapped_column()
|
| 135 |
+
comments: Mapped[int | None] = mapped_column()
|
| 136 |
+
shares: Mapped[int | None] = mapped_column()
|
| 137 |
+
engagement_rate: Mapped[float | None] = mapped_column(Float)
|
| 138 |
+
dimensions: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
|
| 139 |
+
source: Mapped[str] = mapped_column(String(64), nullable=False, default="provider")
|
| 140 |
+
collected_at: Mapped[datetime] = mapped_column(
|
| 141 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 142 |
+
)
|
| 143 |
+
provider_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class AnalyticsPlatformMetric(SocialBase):
|
| 147 |
+
__tablename__ = "analytics_platform_metrics"
|
| 148 |
+
__table_args__ = (
|
| 149 |
+
UniqueConstraint(
|
| 150 |
+
"workspace_id",
|
| 151 |
+
"provider",
|
| 152 |
+
"social_account_id",
|
| 153 |
+
"metric_date",
|
| 154 |
+
name="uq_analytics_platform_metric_bucket",
|
| 155 |
+
),
|
| 156 |
+
Index("ix_analytics_platform_metrics_workspace_date", "workspace_id", "metric_date"),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
|
| 160 |
+
workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 161 |
+
project_id: Mapped[str | None] = mapped_column(String(36))
|
| 162 |
+
social_account_id: Mapped[str] = mapped_column(
|
| 163 |
+
String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False
|
| 164 |
+
)
|
| 165 |
+
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 166 |
+
metric_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 167 |
+
posts_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 168 |
+
views: Mapped[int | None] = mapped_column()
|
| 169 |
+
impressions: Mapped[int | None] = mapped_column()
|
| 170 |
+
likes: Mapped[int | None] = mapped_column()
|
| 171 |
+
comments: Mapped[int | None] = mapped_column()
|
| 172 |
+
shares: Mapped[int | None] = mapped_column()
|
| 173 |
+
engagement_rate: Mapped[float | None] = mapped_column(Float)
|
| 174 |
+
dimensions: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
|
| 175 |
+
source: Mapped[str] = mapped_column(String(64), nullable=False, default="provider")
|
| 176 |
+
collected_at: Mapped[datetime] = mapped_column(
|
| 177 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 178 |
+
)
|
app/analytics/repository.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import or_, select
|
| 6 |
+
from sqlalchemy.exc import IntegrityError
|
| 7 |
+
|
| 8 |
+
from app.analytics.errors import (
|
| 9 |
+
AnalyticsIdempotencyConflictError,
|
| 10 |
+
AnalyticsNotFoundError,
|
| 11 |
+
AnalyticsValidationError,
|
| 12 |
+
)
|
| 13 |
+
from app.analytics.models import (
|
| 14 |
+
AnalyticsMetricSnapshot,
|
| 15 |
+
AnalyticsPlatformMetric,
|
| 16 |
+
AnalyticsPostMetric,
|
| 17 |
+
AnalyticsSyncRun,
|
| 18 |
+
)
|
| 19 |
+
from app.social.database import SocialDatabase
|
| 20 |
+
from app.social.models import SocialPost, SocialPostTarget
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AnalyticsRepository:
|
| 24 |
+
def __init__(self, database: SocialDatabase) -> None:
|
| 25 |
+
self.database = database
|
| 26 |
+
|
| 27 |
+
async def create_sync(self, run: AnalyticsSyncRun) -> AnalyticsSyncRun:
|
| 28 |
+
try:
|
| 29 |
+
async with self.database.session(run.workspace_id) as session:
|
| 30 |
+
session.add(run)
|
| 31 |
+
await session.commit()
|
| 32 |
+
await session.refresh(run)
|
| 33 |
+
return run
|
| 34 |
+
except IntegrityError as exc:
|
| 35 |
+
existing = await self.get_sync_by_idempotency(run.workspace_id, run.idempotency_key)
|
| 36 |
+
if existing is not None:
|
| 37 |
+
if (
|
| 38 |
+
existing.date_from != run.date_from
|
| 39 |
+
or existing.date_to != run.date_to
|
| 40 |
+
or existing.provider != run.provider
|
| 41 |
+
or existing.project_id != run.project_id
|
| 42 |
+
):
|
| 43 |
+
raise AnalyticsIdempotencyConflictError(
|
| 44 |
+
"The analytics idempotency key was already used for a different request."
|
| 45 |
+
) from exc
|
| 46 |
+
return existing
|
| 47 |
+
raise
|
| 48 |
+
|
| 49 |
+
async def get_sync_by_idempotency(
|
| 50 |
+
self, workspace_id: str, idempotency_key: str
|
| 51 |
+
) -> AnalyticsSyncRun | None:
|
| 52 |
+
async with self.database.session(workspace_id) as session:
|
| 53 |
+
return await session.scalar(
|
| 54 |
+
select(AnalyticsSyncRun).where(
|
| 55 |
+
AnalyticsSyncRun.workspace_id == workspace_id,
|
| 56 |
+
AnalyticsSyncRun.idempotency_key == idempotency_key,
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
async def get_sync(self, workspace_id: str, run_id: str) -> AnalyticsSyncRun:
|
| 61 |
+
async with self.database.session(workspace_id) as session:
|
| 62 |
+
run = await session.scalar(
|
| 63 |
+
select(AnalyticsSyncRun).where(
|
| 64 |
+
AnalyticsSyncRun.id == run_id,
|
| 65 |
+
AnalyticsSyncRun.workspace_id == workspace_id,
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
if run is None:
|
| 69 |
+
raise AnalyticsNotFoundError("Analytics sync run was not found.")
|
| 70 |
+
return run
|
| 71 |
+
|
| 72 |
+
async def list_syncs(
|
| 73 |
+
self, workspace_id: str, *, offset: int, limit: int
|
| 74 |
+
) -> list[AnalyticsSyncRun]:
|
| 75 |
+
async with self.database.session(workspace_id) as session:
|
| 76 |
+
return list(
|
| 77 |
+
(
|
| 78 |
+
await session.scalars(
|
| 79 |
+
select(AnalyticsSyncRun)
|
| 80 |
+
.where(AnalyticsSyncRun.workspace_id == workspace_id)
|
| 81 |
+
.order_by(AnalyticsSyncRun.created_at.desc())
|
| 82 |
+
.offset(offset)
|
| 83 |
+
.limit(limit)
|
| 84 |
+
)
|
| 85 |
+
).all()
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
async def claim_due_syncs(self, *, limit: int = 4) -> list[AnalyticsSyncRun]:
|
| 89 |
+
async with self.database.worker_session() as session:
|
| 90 |
+
now = datetime.now(timezone.utc)
|
| 91 |
+
statement = (
|
| 92 |
+
select(AnalyticsSyncRun)
|
| 93 |
+
.where(
|
| 94 |
+
AnalyticsSyncRun.status.in_(["queued", "retrying"]),
|
| 95 |
+
(AnalyticsSyncRun.next_attempt_at.is_(None))
|
| 96 |
+
| (AnalyticsSyncRun.next_attempt_at <= now),
|
| 97 |
+
)
|
| 98 |
+
.order_by(AnalyticsSyncRun.created_at)
|
| 99 |
+
.limit(limit)
|
| 100 |
+
.with_for_update(skip_locked=True)
|
| 101 |
+
)
|
| 102 |
+
runs = list((await session.scalars(statement)).all())
|
| 103 |
+
for run in runs:
|
| 104 |
+
run.status = "running"
|
| 105 |
+
run.started_at = now
|
| 106 |
+
run.attempt_count += 1
|
| 107 |
+
run.updated_at = now
|
| 108 |
+
await session.commit()
|
| 109 |
+
return runs
|
| 110 |
+
|
| 111 |
+
async def update_sync(
|
| 112 |
+
self,
|
| 113 |
+
run_id: str,
|
| 114 |
+
*,
|
| 115 |
+
status: str,
|
| 116 |
+
metrics_count: int | None = None,
|
| 117 |
+
error_code: str | None = None,
|
| 118 |
+
error_message: str | None = None,
|
| 119 |
+
next_attempt_at: datetime | None = None,
|
| 120 |
+
) -> AnalyticsSyncRun:
|
| 121 |
+
async with self.database.worker_session() as session:
|
| 122 |
+
run = await session.get(AnalyticsSyncRun, run_id)
|
| 123 |
+
if run is None:
|
| 124 |
+
raise AnalyticsNotFoundError("Analytics sync run was not found.")
|
| 125 |
+
run.status = status
|
| 126 |
+
run.error_code = error_code
|
| 127 |
+
run.error_message = error_message
|
| 128 |
+
run.next_attempt_at = next_attempt_at
|
| 129 |
+
if metrics_count is not None:
|
| 130 |
+
run.metrics_count = metrics_count
|
| 131 |
+
if status in {"succeeded", "partial", "failed", "cancelled"}:
|
| 132 |
+
run.completed_at = datetime.now(timezone.utc)
|
| 133 |
+
run.updated_at = datetime.now(timezone.utc)
|
| 134 |
+
await session.commit()
|
| 135 |
+
await session.refresh(run)
|
| 136 |
+
return run
|
| 137 |
+
|
| 138 |
+
async def cancel_sync(self, workspace_id: str, run_id: str) -> AnalyticsSyncRun:
|
| 139 |
+
async with self.database.session(workspace_id) as session:
|
| 140 |
+
run = await session.scalar(
|
| 141 |
+
select(AnalyticsSyncRun).where(
|
| 142 |
+
AnalyticsSyncRun.id == run_id,
|
| 143 |
+
AnalyticsSyncRun.workspace_id == workspace_id,
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
if run is None:
|
| 147 |
+
raise AnalyticsNotFoundError("Analytics sync run was not found.")
|
| 148 |
+
if run.status not in {"queued", "running"}:
|
| 149 |
+
raise AnalyticsValidationError(
|
| 150 |
+
"Only queued or running analytics synchronization can be cancelled."
|
| 151 |
+
)
|
| 152 |
+
run.status = "cancelled"
|
| 153 |
+
run.completed_at = datetime.now(timezone.utc)
|
| 154 |
+
run.updated_at = datetime.now(timezone.utc)
|
| 155 |
+
await session.commit()
|
| 156 |
+
await session.refresh(run)
|
| 157 |
+
return run
|
| 158 |
+
|
| 159 |
+
async def published_targets(
|
| 160 |
+
self,
|
| 161 |
+
workspace_id: str,
|
| 162 |
+
*,
|
| 163 |
+
project_id: str | None = None,
|
| 164 |
+
provider: str | None = None,
|
| 165 |
+
date_from: datetime | None = None,
|
| 166 |
+
date_to: datetime | None = None,
|
| 167 |
+
) -> list[tuple[SocialPost, SocialPostTarget]]:
|
| 168 |
+
async with self.database.session(workspace_id) as session:
|
| 169 |
+
statement = (
|
| 170 |
+
select(SocialPost, SocialPostTarget)
|
| 171 |
+
.join(SocialPostTarget, SocialPostTarget.social_post_id == SocialPost.id)
|
| 172 |
+
.where(
|
| 173 |
+
SocialPost.workspace_id == workspace_id,
|
| 174 |
+
SocialPostTarget.external_post_id.is_not(None),
|
| 175 |
+
SocialPostTarget.status == "published",
|
| 176 |
+
)
|
| 177 |
+
)
|
| 178 |
+
if project_id:
|
| 179 |
+
statement = statement.where(SocialPost.project_id == project_id)
|
| 180 |
+
if provider:
|
| 181 |
+
statement = statement.where(SocialPostTarget.provider == provider)
|
| 182 |
+
if date_from:
|
| 183 |
+
statement = statement.where(SocialPostTarget.published_at >= date_from)
|
| 184 |
+
if date_to:
|
| 185 |
+
statement = statement.where(SocialPostTarget.published_at <= date_to)
|
| 186 |
+
return list((await session.execute(statement)).all())
|
| 187 |
+
|
| 188 |
+
async def upsert_post_metric(
|
| 189 |
+
self,
|
| 190 |
+
*,
|
| 191 |
+
workspace_id: str,
|
| 192 |
+
post: SocialPost,
|
| 193 |
+
target: SocialPostTarget,
|
| 194 |
+
metric: dict[str, object],
|
| 195 |
+
collected_at: datetime,
|
| 196 |
+
) -> AnalyticsPostMetric:
|
| 197 |
+
bucket = target.published_at or collected_at
|
| 198 |
+
async with self.database.session(workspace_id) as session:
|
| 199 |
+
existing = await session.scalar(
|
| 200 |
+
select(AnalyticsPostMetric).where(
|
| 201 |
+
AnalyticsPostMetric.workspace_id == workspace_id,
|
| 202 |
+
AnalyticsPostMetric.social_post_target_id == target.id,
|
| 203 |
+
AnalyticsPostMetric.metric_date == bucket,
|
| 204 |
+
)
|
| 205 |
+
)
|
| 206 |
+
values = dict(
|
| 207 |
+
provider=target.provider,
|
| 208 |
+
views=metric.get("views"),
|
| 209 |
+
impressions=metric.get("impressions"),
|
| 210 |
+
likes=metric.get("likes"),
|
| 211 |
+
comments=metric.get("comments"),
|
| 212 |
+
shares=metric.get("shares"),
|
| 213 |
+
engagement_rate=metric.get("engagement_rate"),
|
| 214 |
+
dimensions=(
|
| 215 |
+
metric.get("raw_metrics") if isinstance(metric.get("raw_metrics"), dict) else {}
|
| 216 |
+
),
|
| 217 |
+
source="provider",
|
| 218 |
+
collected_at=collected_at,
|
| 219 |
+
external_post_id=target.external_post_id,
|
| 220 |
+
)
|
| 221 |
+
if existing is None:
|
| 222 |
+
existing = AnalyticsPostMetric(
|
| 223 |
+
workspace_id=workspace_id,
|
| 224 |
+
project_id=post.project_id,
|
| 225 |
+
social_post_id=post.id,
|
| 226 |
+
social_post_target_id=target.id,
|
| 227 |
+
social_account_id=target.social_account_id,
|
| 228 |
+
metric_date=bucket,
|
| 229 |
+
**values,
|
| 230 |
+
)
|
| 231 |
+
session.add(existing)
|
| 232 |
+
else:
|
| 233 |
+
for key, value in values.items():
|
| 234 |
+
setattr(existing, key, value)
|
| 235 |
+
if target.external_post_id:
|
| 236 |
+
for metric_name in (
|
| 237 |
+
"views",
|
| 238 |
+
"impressions",
|
| 239 |
+
"likes",
|
| 240 |
+
"comments",
|
| 241 |
+
"shares",
|
| 242 |
+
"engagement_rate",
|
| 243 |
+
):
|
| 244 |
+
metric_value = metric.get(metric_name)
|
| 245 |
+
if not isinstance(metric_value, (int, float)) or isinstance(metric_value, bool):
|
| 246 |
+
continue
|
| 247 |
+
snapshot = await session.scalar(
|
| 248 |
+
select(AnalyticsMetricSnapshot).where(
|
| 249 |
+
AnalyticsMetricSnapshot.workspace_id == workspace_id,
|
| 250 |
+
AnalyticsMetricSnapshot.provider == target.provider,
|
| 251 |
+
AnalyticsMetricSnapshot.external_object_id == target.external_post_id,
|
| 252 |
+
AnalyticsMetricSnapshot.metric_name == metric_name,
|
| 253 |
+
AnalyticsMetricSnapshot.bucket_start == bucket,
|
| 254 |
+
)
|
| 255 |
+
)
|
| 256 |
+
if snapshot is None:
|
| 257 |
+
session.add(
|
| 258 |
+
AnalyticsMetricSnapshot(
|
| 259 |
+
workspace_id=workspace_id,
|
| 260 |
+
project_id=post.project_id,
|
| 261 |
+
social_post_id=post.id,
|
| 262 |
+
social_account_id=target.social_account_id,
|
| 263 |
+
provider=target.provider,
|
| 264 |
+
external_object_id=target.external_post_id,
|
| 265 |
+
metric_name=metric_name,
|
| 266 |
+
metric_value=float(metric_value),
|
| 267 |
+
bucket_start=bucket,
|
| 268 |
+
dimensions={},
|
| 269 |
+
source="provider",
|
| 270 |
+
collected_at=collected_at,
|
| 271 |
+
)
|
| 272 |
+
)
|
| 273 |
+
else:
|
| 274 |
+
snapshot.metric_value = float(metric_value)
|
| 275 |
+
snapshot.collected_at = collected_at
|
| 276 |
+
await session.flush()
|
| 277 |
+
day_start = bucket.astimezone(timezone.utc).replace(
|
| 278 |
+
hour=0, minute=0, second=0, microsecond=0
|
| 279 |
+
)
|
| 280 |
+
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
| 281 |
+
account_rows = list(
|
| 282 |
+
(
|
| 283 |
+
await session.scalars(
|
| 284 |
+
select(AnalyticsPostMetric).where(
|
| 285 |
+
AnalyticsPostMetric.workspace_id == workspace_id,
|
| 286 |
+
AnalyticsPostMetric.social_account_id == target.social_account_id,
|
| 287 |
+
AnalyticsPostMetric.metric_date >= day_start,
|
| 288 |
+
AnalyticsPostMetric.metric_date <= day_end,
|
| 289 |
+
)
|
| 290 |
+
)
|
| 291 |
+
).all()
|
| 292 |
+
)
|
| 293 |
+
platform = await session.scalar(
|
| 294 |
+
select(AnalyticsPlatformMetric).where(
|
| 295 |
+
AnalyticsPlatformMetric.workspace_id == workspace_id,
|
| 296 |
+
AnalyticsPlatformMetric.provider == target.provider,
|
| 297 |
+
AnalyticsPlatformMetric.social_account_id == target.social_account_id,
|
| 298 |
+
AnalyticsPlatformMetric.metric_date == day_start,
|
| 299 |
+
)
|
| 300 |
+
)
|
| 301 |
+
aggregates: dict[str, int | float | None] = {}
|
| 302 |
+
for name in (
|
| 303 |
+
"views",
|
| 304 |
+
"impressions",
|
| 305 |
+
"likes",
|
| 306 |
+
"comments",
|
| 307 |
+
"shares",
|
| 308 |
+
"engagement_rate",
|
| 309 |
+
):
|
| 310 |
+
metric_values = [
|
| 311 |
+
getattr(item, name) for item in account_rows if getattr(item, name) is not None
|
| 312 |
+
]
|
| 313 |
+
aggregates[name] = (
|
| 314 |
+
(
|
| 315 |
+
sum(metric_values) / len(metric_values)
|
| 316 |
+
if name == "engagement_rate"
|
| 317 |
+
else sum(metric_values)
|
| 318 |
+
)
|
| 319 |
+
if metric_values
|
| 320 |
+
else None
|
| 321 |
+
)
|
| 322 |
+
if platform is None:
|
| 323 |
+
platform = AnalyticsPlatformMetric(
|
| 324 |
+
workspace_id=workspace_id,
|
| 325 |
+
project_id=post.project_id,
|
| 326 |
+
social_account_id=target.social_account_id,
|
| 327 |
+
provider=target.provider,
|
| 328 |
+
metric_date=day_start,
|
| 329 |
+
)
|
| 330 |
+
session.add(platform)
|
| 331 |
+
platform.posts_count = len(account_rows)
|
| 332 |
+
platform.collected_at = collected_at
|
| 333 |
+
for name, value in aggregates.items():
|
| 334 |
+
setattr(platform, name, value)
|
| 335 |
+
await session.commit()
|
| 336 |
+
await session.refresh(existing)
|
| 337 |
+
return existing
|
| 338 |
+
|
| 339 |
+
async def metric_rows(
|
| 340 |
+
self,
|
| 341 |
+
workspace_id: str,
|
| 342 |
+
query: dict[str, object],
|
| 343 |
+
) -> list[AnalyticsPostMetric]:
|
| 344 |
+
async with self.database.session(workspace_id) as session:
|
| 345 |
+
statement = select(AnalyticsPostMetric).where(
|
| 346 |
+
AnalyticsPostMetric.workspace_id == workspace_id,
|
| 347 |
+
AnalyticsPostMetric.metric_date >= query["date_from"],
|
| 348 |
+
AnalyticsPostMetric.metric_date <= query["date_to"],
|
| 349 |
+
)
|
| 350 |
+
for field, model_field in (
|
| 351 |
+
("project_id", AnalyticsPostMetric.project_id),
|
| 352 |
+
("provider", AnalyticsPostMetric.provider),
|
| 353 |
+
("social_account_id", AnalyticsPostMetric.social_account_id),
|
| 354 |
+
("post_id", AnalyticsPostMetric.social_post_id),
|
| 355 |
+
):
|
| 356 |
+
value = query.get(field)
|
| 357 |
+
if value:
|
| 358 |
+
statement = statement.where(model_field == value)
|
| 359 |
+
if query.get("search"):
|
| 360 |
+
pattern = f"%{str(query['search']).strip()}%"
|
| 361 |
+
statement = statement.join(
|
| 362 |
+
SocialPost, SocialPost.id == AnalyticsPostMetric.social_post_id
|
| 363 |
+
).where(
|
| 364 |
+
or_(
|
| 365 |
+
SocialPost.canonical_caption.ilike(pattern),
|
| 366 |
+
SocialPost.id.ilike(pattern),
|
| 367 |
+
)
|
| 368 |
+
)
|
| 369 |
+
return list((await session.scalars(statement)).all())
|
| 370 |
+
|
| 371 |
+
async def latest_sync(
|
| 372 |
+
self, workspace_id: str, *, provider: str | None = None, project_id: str | None = None
|
| 373 |
+
) -> AnalyticsSyncRun | None:
|
| 374 |
+
async with self.database.session(workspace_id) as session:
|
| 375 |
+
statement = select(AnalyticsSyncRun).where(
|
| 376 |
+
AnalyticsSyncRun.workspace_id == workspace_id,
|
| 377 |
+
AnalyticsSyncRun.status.in_(["succeeded", "partial"]),
|
| 378 |
+
)
|
| 379 |
+
if provider:
|
| 380 |
+
statement = statement.where(AnalyticsSyncRun.provider == provider)
|
| 381 |
+
if project_id:
|
| 382 |
+
statement = statement.where(AnalyticsSyncRun.project_id == project_id)
|
| 383 |
+
return await session.scalar(
|
| 384 |
+
statement.order_by(AnalyticsSyncRun.completed_at.desc()).limit(1)
|
| 385 |
+
)
|
app/analytics/schemas.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any, Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 7 |
+
|
| 8 |
+
MetricName = Literal[
|
| 9 |
+
"views",
|
| 10 |
+
"impressions",
|
| 11 |
+
"likes",
|
| 12 |
+
"comments",
|
| 13 |
+
"shares",
|
| 14 |
+
"engagement_rate",
|
| 15 |
+
"reach",
|
| 16 |
+
"clicks",
|
| 17 |
+
"saves",
|
| 18 |
+
"watch_time",
|
| 19 |
+
"completion_rate",
|
| 20 |
+
]
|
| 21 |
+
Granularity = Literal["day", "week", "month"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AnalyticsQuery(BaseModel):
|
| 25 |
+
model_config = ConfigDict(extra="forbid")
|
| 26 |
+
|
| 27 |
+
date_from: datetime | None = None
|
| 28 |
+
date_to: datetime | None = None
|
| 29 |
+
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
| 30 |
+
provider: str | None = Field(default=None, max_length=32)
|
| 31 |
+
project_id: str | None = Field(default=None, max_length=36)
|
| 32 |
+
social_account_id: str | None = Field(default=None, max_length=36)
|
| 33 |
+
post_id: str | None = Field(default=None, max_length=36)
|
| 34 |
+
metric: MetricName | None = None
|
| 35 |
+
granularity: Granularity = "day"
|
| 36 |
+
search: str | None = Field(default=None, max_length=200)
|
| 37 |
+
sort: Literal["date", "views", "engagement_rate", "likes", "comments", "shares"] = "date"
|
| 38 |
+
descending: bool = True
|
| 39 |
+
offset: int = Field(default=0, ge=0, le=100_000)
|
| 40 |
+
limit: int = Field(default=50, ge=1, le=500)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class AnalyticsMetric(BaseModel):
|
| 44 |
+
model_config = ConfigDict(extra="forbid")
|
| 45 |
+
|
| 46 |
+
provider: str
|
| 47 |
+
post_id: str | None = None
|
| 48 |
+
project_id: str | None = None
|
| 49 |
+
metric_date: datetime
|
| 50 |
+
views: int | None = None
|
| 51 |
+
impressions: int | None = None
|
| 52 |
+
likes: int | None = None
|
| 53 |
+
comments: int | None = None
|
| 54 |
+
shares: int | None = None
|
| 55 |
+
engagement_rate: float | None = None
|
| 56 |
+
status: Literal["available", "unavailable", "unsupported", "not_synchronized"] = "available"
|
| 57 |
+
source: str = "provider"
|
| 58 |
+
collected_at: datetime | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class AnalyticsFreshness(BaseModel):
|
| 62 |
+
status: Literal["fresh", "stale", "not_synchronized", "partial", "failed"]
|
| 63 |
+
last_collected_at: datetime | None = None
|
| 64 |
+
last_sync_id: str | None = None
|
| 65 |
+
reason: str | None = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class AnalyticsOverview(BaseModel):
|
| 69 |
+
model_config = ConfigDict(extra="forbid")
|
| 70 |
+
|
| 71 |
+
date_from: datetime
|
| 72 |
+
date_to: datetime
|
| 73 |
+
timezone: str
|
| 74 |
+
timezone_source: Literal["requested", "utc_fallback"] = "requested"
|
| 75 |
+
totals: dict[str, int | float | None]
|
| 76 |
+
platforms: list[dict[str, Any]]
|
| 77 |
+
top_posts: list[AnalyticsMetric]
|
| 78 |
+
freshness: AnalyticsFreshness
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class AnalyticsTimeseries(BaseModel):
|
| 82 |
+
date_from: datetime
|
| 83 |
+
date_to: datetime
|
| 84 |
+
timezone: str
|
| 85 |
+
granularity: Granularity
|
| 86 |
+
metric: str
|
| 87 |
+
points: list[dict[str, Any]]
|
| 88 |
+
freshness: AnalyticsFreshness
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class AnalyticsSyncRequest(BaseModel):
|
| 92 |
+
model_config = ConfigDict(extra="forbid")
|
| 93 |
+
|
| 94 |
+
date_from: datetime | None = None
|
| 95 |
+
date_to: datetime | None = None
|
| 96 |
+
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
| 97 |
+
provider: str | None = Field(default=None, max_length=32)
|
| 98 |
+
project_id: str | None = Field(default=None, max_length=36)
|
| 99 |
+
idempotency_key: str | None = Field(default=None, min_length=8, max_length=255)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class AnalyticsSyncRunView(BaseModel):
|
| 103 |
+
model_config = ConfigDict(from_attributes=True, extra="forbid")
|
| 104 |
+
|
| 105 |
+
id: str
|
| 106 |
+
status: Literal["queued", "running", "succeeded", "partial", "failed", "cancelled"]
|
| 107 |
+
provider: str | None = None
|
| 108 |
+
project_id: str | None = None
|
| 109 |
+
date_from: datetime
|
| 110 |
+
date_to: datetime
|
| 111 |
+
timezone: str
|
| 112 |
+
attempt_count: int
|
| 113 |
+
metrics_count: int
|
| 114 |
+
error_code: str | None = None
|
| 115 |
+
error_message: str | None = None
|
| 116 |
+
created_at: datetime
|
| 117 |
+
started_at: datetime | None = None
|
| 118 |
+
completed_at: datetime | None = None
|
| 119 |
+
updated_at: datetime
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class AnalyticsCapabilities(BaseModel):
|
| 123 |
+
provider: str
|
| 124 |
+
implementation_status: str
|
| 125 |
+
status: Literal["available", "unsupported", "unavailable"]
|
| 126 |
+
metrics: list[str]
|
| 127 |
+
required_scopes: list[str] = Field(default_factory=list)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class AnalyticsSyncList(BaseModel):
|
| 131 |
+
items: list[AnalyticsSyncRunView]
|
| 132 |
+
offset: int
|
| 133 |
+
limit: int
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class AnalyticsPostList(BaseModel):
|
| 137 |
+
items: list[AnalyticsMetric]
|
| 138 |
+
offset: int
|
| 139 |
+
limit: int
|
| 140 |
+
freshness: AnalyticsFreshness
|
app/analytics/service.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Any
|
| 6 |
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
| 7 |
+
|
| 8 |
+
from app.analytics.errors import AnalyticsValidationError
|
| 9 |
+
from app.analytics.models import AnalyticsSyncRun
|
| 10 |
+
from app.analytics.repository import AnalyticsRepository
|
| 11 |
+
from app.analytics.schemas import (
|
| 12 |
+
AnalyticsCapabilities,
|
| 13 |
+
AnalyticsFreshness,
|
| 14 |
+
AnalyticsMetric,
|
| 15 |
+
AnalyticsOverview,
|
| 16 |
+
AnalyticsPostList,
|
| 17 |
+
AnalyticsQuery,
|
| 18 |
+
AnalyticsSyncRequest,
|
| 19 |
+
AnalyticsSyncRunView,
|
| 20 |
+
AnalyticsTimeseries,
|
| 21 |
+
)
|
| 22 |
+
from app.core.logger import get_logger
|
| 23 |
+
from app.social.providers.registry import ProviderRegistry
|
| 24 |
+
from app.social.services.analytics_service import AnalyticsService as SocialAnalyticsService
|
| 25 |
+
from app.social.services.audit_service import SocialAuditService
|
| 26 |
+
|
| 27 |
+
logger = get_logger(__name__)
|
| 28 |
+
COMMON_METRICS = ("views", "impressions", "likes", "comments", "shares", "engagement_rate")
|
| 29 |
+
PROVIDER_METRICS: dict[str, tuple[str, ...]] = {
|
| 30 |
+
"facebook": ("views", "impressions", "likes", "comments", "shares"),
|
| 31 |
+
"instagram": ("views", "likes", "comments", "shares"),
|
| 32 |
+
"tiktok": ("views", "likes", "comments", "shares"),
|
| 33 |
+
"x": ("impressions", "likes", "comments", "shares"),
|
| 34 |
+
"youtube": ("views", "likes", "comments"),
|
| 35 |
+
"linkedin": ("impressions", "likes", "comments", "shares", "engagement_rate"),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class AnalyticsDomainService:
|
| 40 |
+
"""Tenant-scoped analytics aggregation over provider-reported snapshots."""
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
repository: AnalyticsRepository,
|
| 45 |
+
providers: ProviderRegistry,
|
| 46 |
+
social_analytics: SocialAnalyticsService,
|
| 47 |
+
accounts: object,
|
| 48 |
+
audit: SocialAuditService,
|
| 49 |
+
projects: object | None = None,
|
| 50 |
+
) -> None:
|
| 51 |
+
self.repository = repository
|
| 52 |
+
self.providers = providers
|
| 53 |
+
self.social_analytics = social_analytics
|
| 54 |
+
self.accounts = accounts
|
| 55 |
+
self.audit = audit
|
| 56 |
+
self.projects = projects
|
| 57 |
+
self.ready = False
|
| 58 |
+
|
| 59 |
+
async def initialize(self, social_ready: bool) -> None:
|
| 60 |
+
self.ready = social_ready
|
| 61 |
+
|
| 62 |
+
def ensure_ready(self) -> None:
|
| 63 |
+
if not self.ready:
|
| 64 |
+
from app.social.domain.errors import SocialProviderUnavailableError
|
| 65 |
+
|
| 66 |
+
raise SocialProviderUnavailableError(
|
| 67 |
+
"Analytics database schema is unavailable. Apply the analytics migration."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
def _range(query: AnalyticsQuery) -> tuple[datetime, datetime, str]:
|
| 72 |
+
end = query.date_to or datetime.now(timezone.utc)
|
| 73 |
+
start = query.date_from or end - timedelta(days=30)
|
| 74 |
+
if start.tzinfo is None or end.tzinfo is None:
|
| 75 |
+
raise AnalyticsValidationError("Analytics dates must include a UTC offset.")
|
| 76 |
+
if end <= start:
|
| 77 |
+
raise AnalyticsValidationError("date_to must be after date_from.")
|
| 78 |
+
if end - start > timedelta(days=366):
|
| 79 |
+
raise AnalyticsValidationError("Analytics date ranges are limited to 366 days.")
|
| 80 |
+
try:
|
| 81 |
+
ZoneInfo(query.timezone)
|
| 82 |
+
except ZoneInfoNotFoundError as exc:
|
| 83 |
+
raise AnalyticsValidationError("timezone must be a valid IANA timezone.") from exc
|
| 84 |
+
return start.astimezone(timezone.utc), end.astimezone(timezone.utc), query.timezone
|
| 85 |
+
|
| 86 |
+
@staticmethod
|
| 87 |
+
def _metric_view(row: Any) -> AnalyticsMetric:
|
| 88 |
+
return AnalyticsMetric(
|
| 89 |
+
provider=row.provider,
|
| 90 |
+
post_id=row.social_post_id,
|
| 91 |
+
project_id=row.project_id,
|
| 92 |
+
metric_date=row.metric_date,
|
| 93 |
+
views=row.views,
|
| 94 |
+
impressions=row.impressions,
|
| 95 |
+
likes=row.likes,
|
| 96 |
+
comments=row.comments,
|
| 97 |
+
shares=row.shares,
|
| 98 |
+
engagement_rate=row.engagement_rate,
|
| 99 |
+
collected_at=row.collected_at,
|
| 100 |
+
source=row.source,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
async def capabilities(self) -> list[AnalyticsCapabilities]:
|
| 104 |
+
result: list[AnalyticsCapabilities] = []
|
| 105 |
+
for adapter in self.providers.list():
|
| 106 |
+
capabilities = adapter.capabilities
|
| 107 |
+
available = bool(capabilities.analytics)
|
| 108 |
+
result.append(
|
| 109 |
+
AnalyticsCapabilities(
|
| 110 |
+
provider=capabilities.provider.value,
|
| 111 |
+
implementation_status=capabilities.implementation_status,
|
| 112 |
+
status="available" if available else "unsupported",
|
| 113 |
+
metrics=(
|
| 114 |
+
list(PROVIDER_METRICS.get(capabilities.provider.value, ()))
|
| 115 |
+
if available
|
| 116 |
+
else []
|
| 117 |
+
),
|
| 118 |
+
required_scopes=list(capabilities.analytics_required_scopes),
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
return result
|
| 122 |
+
|
| 123 |
+
async def create_sync(
|
| 124 |
+
self, workspace_id: str, user_id: str, payload: AnalyticsSyncRequest
|
| 125 |
+
) -> AnalyticsSyncRunView:
|
| 126 |
+
query = AnalyticsQuery(
|
| 127 |
+
date_from=payload.date_from,
|
| 128 |
+
date_to=payload.date_to,
|
| 129 |
+
timezone=payload.timezone,
|
| 130 |
+
provider=payload.provider,
|
| 131 |
+
project_id=payload.project_id,
|
| 132 |
+
)
|
| 133 |
+
start, end, tz = self._range(query)
|
| 134 |
+
if not payload.idempotency_key:
|
| 135 |
+
raise AnalyticsValidationError(
|
| 136 |
+
"Idempotency-Key is required for analytics synchronization."
|
| 137 |
+
)
|
| 138 |
+
if payload.provider:
|
| 139 |
+
self.providers.get(payload.provider)
|
| 140 |
+
if payload.project_id and self.projects is not None:
|
| 141 |
+
await self.projects.get(
|
| 142 |
+
workspace_id=workspace_id,
|
| 143 |
+
user_id=user_id,
|
| 144 |
+
project_id=payload.project_id,
|
| 145 |
+
)
|
| 146 |
+
run = await self.repository.create_sync(
|
| 147 |
+
AnalyticsSyncRun(
|
| 148 |
+
workspace_id=workspace_id,
|
| 149 |
+
project_id=payload.project_id,
|
| 150 |
+
provider=payload.provider,
|
| 151 |
+
date_from=start,
|
| 152 |
+
date_to=end,
|
| 153 |
+
timezone=tz,
|
| 154 |
+
idempotency_key=payload.idempotency_key,
|
| 155 |
+
requested_by=user_id,
|
| 156 |
+
)
|
| 157 |
+
)
|
| 158 |
+
await self.audit.record(
|
| 159 |
+
workspace_id=workspace_id,
|
| 160 |
+
event_type="analytics.sync_requested",
|
| 161 |
+
metadata={"sync_run_id": run.id, "provider": payload.provider},
|
| 162 |
+
)
|
| 163 |
+
return self._sync_view(run)
|
| 164 |
+
|
| 165 |
+
async def sync_once(self, run: AnalyticsSyncRun) -> AnalyticsSyncRunView:
|
| 166 |
+
targets = await self.repository.published_targets(
|
| 167 |
+
run.workspace_id,
|
| 168 |
+
project_id=run.project_id,
|
| 169 |
+
provider=run.provider,
|
| 170 |
+
date_from=run.date_from,
|
| 171 |
+
date_to=run.date_to,
|
| 172 |
+
)
|
| 173 |
+
await self.audit.record(
|
| 174 |
+
workspace_id=run.workspace_id,
|
| 175 |
+
event_type="analytics.sync_started",
|
| 176 |
+
metadata={"sync_run_id": run.id},
|
| 177 |
+
)
|
| 178 |
+
errors = 0
|
| 179 |
+
count = 0
|
| 180 |
+
by_post: dict[str, tuple[Any, list[Any]]] = {}
|
| 181 |
+
for post, target in targets:
|
| 182 |
+
by_post.setdefault(post.id, (post, []))[1].append(target)
|
| 183 |
+
for post, post_targets in by_post.values():
|
| 184 |
+
try:
|
| 185 |
+
latest = await self.repository.get_sync(run.workspace_id, run.id)
|
| 186 |
+
if latest.status == "cancelled":
|
| 187 |
+
return self._sync_view(latest)
|
| 188 |
+
result = await self.social_analytics.post(run.workspace_id, post.id)
|
| 189 |
+
for target in post_targets:
|
| 190 |
+
matches = [
|
| 191 |
+
item
|
| 192 |
+
for item in result.get("metrics", [])
|
| 193 |
+
if isinstance(item, dict) and item.get("provider") == target.provider
|
| 194 |
+
]
|
| 195 |
+
if not matches:
|
| 196 |
+
errors += 1
|
| 197 |
+
continue
|
| 198 |
+
metric = matches[-1]
|
| 199 |
+
await self.repository.upsert_post_metric(
|
| 200 |
+
workspace_id=run.workspace_id,
|
| 201 |
+
post=post,
|
| 202 |
+
target=target,
|
| 203 |
+
metric=metric,
|
| 204 |
+
collected_at=datetime.now(timezone.utc),
|
| 205 |
+
)
|
| 206 |
+
count += 1
|
| 207 |
+
if result.get("unavailable"):
|
| 208 |
+
errors += 1
|
| 209 |
+
except Exception as exc:
|
| 210 |
+
errors += 1
|
| 211 |
+
logger.warning(
|
| 212 |
+
"analytics target synchronization failed",
|
| 213 |
+
extra={"sync_run_id": run.id, "post_id": post.id},
|
| 214 |
+
)
|
| 215 |
+
if not getattr(exc, "code", None):
|
| 216 |
+
logger.debug("analytics sync exception", exc_info=True)
|
| 217 |
+
status = (
|
| 218 |
+
"failed" if targets and count == 0 and errors else "partial" if errors else "succeeded"
|
| 219 |
+
)
|
| 220 |
+
if not targets:
|
| 221 |
+
status = "succeeded"
|
| 222 |
+
updated = await self.repository.update_sync(run.id, status=status, metrics_count=count)
|
| 223 |
+
await self.audit.record(
|
| 224 |
+
workspace_id=run.workspace_id,
|
| 225 |
+
event_type=(
|
| 226 |
+
"analytics.sync_completed" if status != "failed" else "analytics.sync_failed"
|
| 227 |
+
),
|
| 228 |
+
metadata={"sync_run_id": run.id, "status": status, "metrics_count": count},
|
| 229 |
+
)
|
| 230 |
+
return self._sync_view(updated)
|
| 231 |
+
|
| 232 |
+
async def overview(self, workspace_id: str, query: AnalyticsQuery) -> AnalyticsOverview:
|
| 233 |
+
start, end, tz = self._range(query)
|
| 234 |
+
rows = await self.repository.metric_rows(
|
| 235 |
+
workspace_id,
|
| 236 |
+
{
|
| 237 |
+
"date_from": start,
|
| 238 |
+
"date_to": end,
|
| 239 |
+
"provider": query.provider,
|
| 240 |
+
"project_id": query.project_id,
|
| 241 |
+
"social_account_id": query.social_account_id,
|
| 242 |
+
"post_id": query.post_id,
|
| 243 |
+
"search": query.search,
|
| 244 |
+
},
|
| 245 |
+
)
|
| 246 |
+
totals = self._totals(rows)
|
| 247 |
+
grouped: dict[str, list[Any]] = defaultdict(list)
|
| 248 |
+
for row in rows:
|
| 249 |
+
grouped[row.provider].append(row)
|
| 250 |
+
platforms = [
|
| 251 |
+
{"provider": provider, "posts": len(items), **self._totals(items)}
|
| 252 |
+
for provider, items in sorted(grouped.items())
|
| 253 |
+
]
|
| 254 |
+
top = sorted(
|
| 255 |
+
rows, key=lambda row: self._sort_value(row, query.sort), reverse=query.descending
|
| 256 |
+
)[:10]
|
| 257 |
+
return AnalyticsOverview(
|
| 258 |
+
date_from=start,
|
| 259 |
+
date_to=end,
|
| 260 |
+
timezone=tz,
|
| 261 |
+
timezone_source="requested" if query.timezone != "UTC" else "utc_fallback",
|
| 262 |
+
totals=totals,
|
| 263 |
+
platforms=platforms,
|
| 264 |
+
top_posts=[self._metric_view(row) for row in top],
|
| 265 |
+
freshness=await self._freshness(workspace_id, query),
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
async def timeseries(self, workspace_id: str, query: AnalyticsQuery) -> AnalyticsTimeseries:
|
| 269 |
+
start, end, tz_name = self._range(query)
|
| 270 |
+
rows = await self.repository.metric_rows(
|
| 271 |
+
workspace_id,
|
| 272 |
+
{
|
| 273 |
+
"date_from": start,
|
| 274 |
+
"date_to": end,
|
| 275 |
+
"provider": query.provider,
|
| 276 |
+
"project_id": query.project_id,
|
| 277 |
+
"social_account_id": query.social_account_id,
|
| 278 |
+
"post_id": query.post_id,
|
| 279 |
+
"search": query.search,
|
| 280 |
+
},
|
| 281 |
+
)
|
| 282 |
+
zone = ZoneInfo(tz_name)
|
| 283 |
+
points: dict[str, dict[str, Any]] = {}
|
| 284 |
+
for row in rows:
|
| 285 |
+
local = row.metric_date.astimezone(zone)
|
| 286 |
+
if query.granularity == "month":
|
| 287 |
+
key = local.strftime("%Y-%m-01")
|
| 288 |
+
elif query.granularity == "week":
|
| 289 |
+
monday = local.date() - timedelta(days=local.weekday())
|
| 290 |
+
key = monday.isoformat()
|
| 291 |
+
else:
|
| 292 |
+
key = local.date().isoformat()
|
| 293 |
+
item = points.setdefault(key, {"bucket": key, "value": 0, "posts": 0})
|
| 294 |
+
value = getattr(row, query.metric or "views")
|
| 295 |
+
if value is not None:
|
| 296 |
+
item["value"] += value
|
| 297 |
+
item["posts"] += 1
|
| 298 |
+
return AnalyticsTimeseries(
|
| 299 |
+
date_from=start,
|
| 300 |
+
date_to=end,
|
| 301 |
+
timezone=tz_name,
|
| 302 |
+
granularity=query.granularity,
|
| 303 |
+
metric=query.metric or "views",
|
| 304 |
+
points=[points[key] for key in sorted(points)],
|
| 305 |
+
freshness=await self._freshness(workspace_id, query),
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
async def posts(self, workspace_id: str, query: AnalyticsQuery) -> AnalyticsPostList:
|
| 309 |
+
start, end, _ = self._range(query)
|
| 310 |
+
rows = await self.repository.metric_rows(
|
| 311 |
+
workspace_id,
|
| 312 |
+
{
|
| 313 |
+
"date_from": start,
|
| 314 |
+
"date_to": end,
|
| 315 |
+
"provider": query.provider,
|
| 316 |
+
"project_id": query.project_id,
|
| 317 |
+
"social_account_id": query.social_account_id,
|
| 318 |
+
"post_id": query.post_id,
|
| 319 |
+
"search": query.search,
|
| 320 |
+
},
|
| 321 |
+
)
|
| 322 |
+
rows.sort(key=lambda row: self._sort_value(row, query.sort), reverse=query.descending)
|
| 323 |
+
return AnalyticsPostList(
|
| 324 |
+
items=[
|
| 325 |
+
self._metric_view(row) for row in rows[query.offset : query.offset + query.limit]
|
| 326 |
+
],
|
| 327 |
+
offset=query.offset,
|
| 328 |
+
limit=query.limit,
|
| 329 |
+
freshness=await self._freshness(workspace_id, query),
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
async def post(
|
| 333 |
+
self, workspace_id: str, post_id: str, query: AnalyticsQuery
|
| 334 |
+
) -> AnalyticsPostList:
|
| 335 |
+
query.post_id = post_id
|
| 336 |
+
return await self.posts(workspace_id, query)
|
| 337 |
+
|
| 338 |
+
async def _freshness(self, workspace_id: str, query: AnalyticsQuery) -> AnalyticsFreshness:
|
| 339 |
+
run = await self.repository.latest_sync(
|
| 340 |
+
workspace_id, provider=query.provider, project_id=query.project_id
|
| 341 |
+
)
|
| 342 |
+
if run is None:
|
| 343 |
+
return AnalyticsFreshness(
|
| 344 |
+
status="not_synchronized", reason="No analytics sync has completed."
|
| 345 |
+
)
|
| 346 |
+
status = (
|
| 347 |
+
"fresh"
|
| 348 |
+
if run.completed_at
|
| 349 |
+
and run.completed_at >= datetime.now(timezone.utc) - timedelta(hours=24)
|
| 350 |
+
else "stale"
|
| 351 |
+
)
|
| 352 |
+
if run.status == "partial":
|
| 353 |
+
status = "partial"
|
| 354 |
+
return AnalyticsFreshness(
|
| 355 |
+
status=status, last_collected_at=run.completed_at, last_sync_id=run.id
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
@staticmethod
|
| 359 |
+
def _totals(rows: list[Any]) -> dict[str, int | float | None]:
|
| 360 |
+
result: dict[str, int | float | None] = {}
|
| 361 |
+
for name in COMMON_METRICS:
|
| 362 |
+
values = [getattr(row, name) for row in rows if getattr(row, name) is not None]
|
| 363 |
+
result[name] = (
|
| 364 |
+
(sum(values) / len(values) if name == "engagement_rate" else sum(values))
|
| 365 |
+
if values
|
| 366 |
+
else None
|
| 367 |
+
)
|
| 368 |
+
return result
|
| 369 |
+
|
| 370 |
+
@staticmethod
|
| 371 |
+
def _sort_value(row: Any, field: str) -> float:
|
| 372 |
+
if field == "date":
|
| 373 |
+
return row.metric_date.timestamp()
|
| 374 |
+
value = getattr(row, field, None)
|
| 375 |
+
return float(value) if isinstance(value, (int, float)) else 0.0
|
| 376 |
+
|
| 377 |
+
@staticmethod
|
| 378 |
+
def _sync_view(run: AnalyticsSyncRun) -> AnalyticsSyncRunView:
|
| 379 |
+
return AnalyticsSyncRunView.model_validate(run, from_attributes=True)
|
app/analytics/workers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Durable analytics synchronization workers."""
|
app/analytics/workers/sync.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import random
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
|
| 7 |
+
from app.analytics.service import AnalyticsDomainService
|
| 8 |
+
from app.core.logger import get_logger
|
| 9 |
+
from app.social.database import SocialDatabase
|
| 10 |
+
|
| 11 |
+
logger = get_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AnalyticsSyncWorker:
|
| 15 |
+
"""Claims durable sync runs and executes bounded provider synchronization."""
|
| 16 |
+
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
analytics: AnalyticsDomainService,
|
| 20 |
+
database: SocialDatabase,
|
| 21 |
+
*,
|
| 22 |
+
interval_seconds: int,
|
| 23 |
+
concurrency: int = 2,
|
| 24 |
+
) -> None:
|
| 25 |
+
self.analytics = analytics
|
| 26 |
+
self.database = database
|
| 27 |
+
self.interval_seconds = max(1, interval_seconds)
|
| 28 |
+
self.concurrency = max(1, min(concurrency, 8))
|
| 29 |
+
self._task: asyncio.Task[None] | None = None
|
| 30 |
+
self._stop = asyncio.Event()
|
| 31 |
+
|
| 32 |
+
async def start(self) -> None:
|
| 33 |
+
if self._task is not None or not self.analytics.ready:
|
| 34 |
+
return
|
| 35 |
+
self._stop.clear()
|
| 36 |
+
self._task = asyncio.create_task(self._run(), name="analytics-sync-worker")
|
| 37 |
+
|
| 38 |
+
async def stop(self) -> None:
|
| 39 |
+
self._stop.set()
|
| 40 |
+
if self._task is not None:
|
| 41 |
+
self._task.cancel()
|
| 42 |
+
await asyncio.gather(self._task, return_exceptions=True)
|
| 43 |
+
self._task = None
|
| 44 |
+
|
| 45 |
+
async def run_once(self) -> None:
|
| 46 |
+
if not self.analytics.ready:
|
| 47 |
+
return
|
| 48 |
+
async with self.database.worker_boundary():
|
| 49 |
+
runs = await self.analytics.repository.claim_due_syncs(limit=self.concurrency)
|
| 50 |
+
await asyncio.gather(*(self._execute(run) for run in runs))
|
| 51 |
+
|
| 52 |
+
async def _execute(self, run) -> None:
|
| 53 |
+
try:
|
| 54 |
+
latest = await self.analytics.repository.get_sync(run.workspace_id, run.id)
|
| 55 |
+
if latest.status == "cancelled":
|
| 56 |
+
return
|
| 57 |
+
await self.analytics.sync_once(run)
|
| 58 |
+
except asyncio.CancelledError:
|
| 59 |
+
raise
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
retryable = run.attempt_count < 3
|
| 62 |
+
if retryable:
|
| 63 |
+
delay = min(900, 30 * (2 ** max(0, run.attempt_count - 1))) + random.randint(0, 10)
|
| 64 |
+
await self.analytics.repository.update_sync(
|
| 65 |
+
run.id,
|
| 66 |
+
status="queued",
|
| 67 |
+
error_code=getattr(exc, "code", "ANALYTICS_SYNC_RETRY"),
|
| 68 |
+
error_message="Analytics synchronization will be retried.",
|
| 69 |
+
next_attempt_at=datetime.now(timezone.utc) + timedelta(seconds=delay),
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
await self.analytics.repository.update_sync(
|
| 73 |
+
run.id,
|
| 74 |
+
status="failed",
|
| 75 |
+
error_code=getattr(exc, "code", "ANALYTICS_SYNC_FAILED"),
|
| 76 |
+
error_message="Analytics synchronization failed.",
|
| 77 |
+
)
|
| 78 |
+
await self.analytics.audit.record(
|
| 79 |
+
workspace_id=run.workspace_id,
|
| 80 |
+
event_type="analytics.sync_failed",
|
| 81 |
+
metadata={"sync_run_id": run.id, "error_code": getattr(exc, "code", None)},
|
| 82 |
+
)
|
| 83 |
+
logger.warning(
|
| 84 |
+
"analytics sync execution failed",
|
| 85 |
+
extra={"sync_run_id": run.id, "attempt": run.attempt_count},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
async def _run(self) -> None:
|
| 89 |
+
while not self._stop.is_set():
|
| 90 |
+
try:
|
| 91 |
+
await self.run_once()
|
| 92 |
+
except asyncio.CancelledError:
|
| 93 |
+
raise
|
| 94 |
+
except Exception:
|
| 95 |
+
logger.exception("analytics sync worker iteration failed")
|
| 96 |
+
try:
|
| 97 |
+
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
|
| 98 |
+
except TimeoutError:
|
| 99 |
+
pass
|
app/api/api_keys.py
CHANGED
|
@@ -38,6 +38,9 @@ async def current_auth_context(request: Request) -> AuthContextView:
|
|
| 38 |
role=context.role,
|
| 39 |
scopes=sorted(context.scopes),
|
| 40 |
expires_at=context.expires_at,
|
|
|
|
|
|
|
|
|
|
| 41 |
)
|
| 42 |
|
| 43 |
|
|
@@ -80,7 +83,10 @@ async def create_api_key(request: Request, payload: APIKeyCreate) -> APIKeyCreat
|
|
| 80 |
context = request.state.auth
|
| 81 |
try:
|
| 82 |
record, secret = await request.app.state.container.api_keys.create(
|
| 83 |
-
payload,
|
|
|
|
|
|
|
|
|
|
| 84 |
)
|
| 85 |
except APIKeyConflictError as exc:
|
| 86 |
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
|
|
| 38 |
role=context.role,
|
| 39 |
scopes=sorted(context.scopes),
|
| 40 |
expires_at=context.expires_at,
|
| 41 |
+
workspace_id=context.workspace_id,
|
| 42 |
+
user_id=context.user_id,
|
| 43 |
+
membership_role=context.membership_role,
|
| 44 |
)
|
| 45 |
|
| 46 |
|
|
|
|
| 83 |
context = request.state.auth
|
| 84 |
try:
|
| 85 |
record, secret = await request.app.state.container.api_keys.create(
|
| 86 |
+
payload,
|
| 87 |
+
created_by=context.api_key_id,
|
| 88 |
+
workspace_id=context.workspace_id,
|
| 89 |
+
user_id=context.user_id,
|
| 90 |
)
|
| 91 |
except APIKeyConflictError as exc:
|
| 92 |
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
app/api/generation.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Header, HTTPException, Query, Request, status
|
| 6 |
+
|
| 7 |
+
from app.generation.schemas.requests import (
|
| 8 |
+
GenerationJobView,
|
| 9 |
+
GenerationProviderView,
|
| 10 |
+
GenerationRequestCreate,
|
| 11 |
+
GenerationRequestView,
|
| 12 |
+
)
|
| 13 |
+
from app.generation.model_registry import GenerationModelView
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/v1/generation", tags=["generation"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _generation(request: Request):
|
| 19 |
+
service = request.app.state.container.generation
|
| 20 |
+
service.ensure_ready()
|
| 21 |
+
return service
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _identity(request: Request) -> tuple[str, str]:
|
| 25 |
+
context = request.state.auth
|
| 26 |
+
if not context.workspace_id or not context.user_id:
|
| 27 |
+
# The credential itself is never a workspace. API-key middleware
|
| 28 |
+
# resolves the authoritative membership before this route runs.
|
| 29 |
+
raise HTTPException(status_code=403, detail="No active workspace membership.")
|
| 30 |
+
return context.workspace_id, context.user_id
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/providers", response_model=list[GenerationProviderView])
|
| 34 |
+
async def list_providers(request: Request) -> list[GenerationProviderView]:
|
| 35 |
+
return _generation(request).list_providers()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get(
|
| 39 |
+
"/providers/{provider}", response_model=GenerationProviderView
|
| 40 |
+
)
|
| 41 |
+
async def get_provider(request: Request, provider: str) -> GenerationProviderView:
|
| 42 |
+
return _generation(request).get_provider(provider)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.get("/models", response_model=list[GenerationModelView])
|
| 46 |
+
async def list_models(
|
| 47 |
+
request: Request,
|
| 48 |
+
provider: str | None = Query(default=None, min_length=1, max_length=64),
|
| 49 |
+
) -> list[GenerationModelView]:
|
| 50 |
+
return _generation(request).list_models(provider=provider)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get("/providers/{provider}/models/{model_id}", response_model=GenerationModelView)
|
| 54 |
+
async def get_model(
|
| 55 |
+
request: Request, provider: str, model_id: str
|
| 56 |
+
) -> GenerationModelView:
|
| 57 |
+
return _generation(request).get_model(provider, model_id)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post(
|
| 61 |
+
"/requests",
|
| 62 |
+
response_model=GenerationRequestView,
|
| 63 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 64 |
+
)
|
| 65 |
+
async def create_request(
|
| 66 |
+
request: Request,
|
| 67 |
+
payload: GenerationRequestCreate,
|
| 68 |
+
idempotency_key: Annotated[
|
| 69 |
+
str, Header(alias="Idempotency-Key", min_length=8, max_length=255)
|
| 70 |
+
],
|
| 71 |
+
) -> GenerationRequestView:
|
| 72 |
+
workspace_id, user_id = _identity(request)
|
| 73 |
+
return await _generation(request).create(
|
| 74 |
+
workspace_id=workspace_id,
|
| 75 |
+
user_id=user_id,
|
| 76 |
+
payload=payload,
|
| 77 |
+
idempotency_key=idempotency_key,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get("/requests", response_model=list[GenerationRequestView])
|
| 82 |
+
async def list_requests(
|
| 83 |
+
request: Request,
|
| 84 |
+
offset: int = Query(default=0, ge=0),
|
| 85 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 86 |
+
) -> list[GenerationRequestView]:
|
| 87 |
+
workspace_id, user_id = _identity(request)
|
| 88 |
+
return await _generation(request).list_requests(
|
| 89 |
+
workspace_id, user_id, offset=offset, limit=limit
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.get("/requests/{generation_request_id}", response_model=GenerationRequestView)
|
| 94 |
+
async def get_request(
|
| 95 |
+
request: Request, generation_request_id: str
|
| 96 |
+
) -> GenerationRequestView:
|
| 97 |
+
workspace_id, user_id = _identity(request)
|
| 98 |
+
return await _generation(request).get_request(
|
| 99 |
+
workspace_id, user_id, generation_request_id
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.get("/jobs/{generation_job_id}", response_model=GenerationJobView)
|
| 104 |
+
async def get_job(request: Request, generation_job_id: str) -> GenerationJobView:
|
| 105 |
+
workspace_id, user_id = _identity(request)
|
| 106 |
+
return await _generation(request).get_job(
|
| 107 |
+
workspace_id, user_id, generation_job_id
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.post(
|
| 112 |
+
"/jobs/{generation_job_id}/cancel", response_model=GenerationJobView
|
| 113 |
+
)
|
| 114 |
+
async def cancel_job(request: Request, generation_job_id: str) -> GenerationJobView:
|
| 115 |
+
workspace_id, user_id = _identity(request)
|
| 116 |
+
return await _generation(request).cancel(
|
| 117 |
+
workspace_id, user_id, generation_job_id
|
| 118 |
+
)
|
app/api/media.py
CHANGED
|
@@ -7,7 +7,9 @@ import aiofiles
|
|
| 7 |
from fastapi import APIRouter, Request
|
| 8 |
from fastapi.responses import StreamingResponse
|
| 9 |
|
|
|
|
| 10 |
from app.core.response import SuccessResponse
|
|
|
|
| 11 |
from app.services.media_service import MediaProcessor, Operation
|
| 12 |
|
| 13 |
router = APIRouter(tags=["media"])
|
|
@@ -34,7 +36,25 @@ async def stream_file(path: Path) -> AsyncIterator[bytes]:
|
|
| 34 |
async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
|
| 35 |
request.state.operation = "media.download"
|
| 36 |
container = request.app.state.container
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
media_type = container.validator.infer_mime(path)
|
| 39 |
headers = {
|
| 40 |
"Content-Disposition": f'attachment; filename="{path.name}"',
|
|
|
|
| 7 |
from fastapi import APIRouter, Request
|
| 8 |
from fastapi.responses import StreamingResponse
|
| 9 |
|
| 10 |
+
from app.core.exceptions import NotFoundError
|
| 11 |
from app.core.response import SuccessResponse
|
| 12 |
+
from app.security.assets import CanonicalAssetNotFoundError
|
| 13 |
from app.services.media_service import MediaProcessor, Operation
|
| 14 |
|
| 15 |
router = APIRouter(tags=["media"])
|
|
|
|
| 36 |
async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
|
| 37 |
request.state.operation = "media.download"
|
| 38 |
container = request.app.state.container
|
| 39 |
+
# The filesystem locator is not an authorization token. In authenticated
|
| 40 |
+
# deployments an output must have been issued by the pipeline to the
|
| 41 |
+
# caller's authoritative workspace before it can be downloaded.
|
| 42 |
+
if container.settings.auth_enabled:
|
| 43 |
+
context = getattr(request.state, "auth", None)
|
| 44 |
+
if context is None or not context.workspace_id:
|
| 45 |
+
raise NotFoundError("Output file not found")
|
| 46 |
+
try:
|
| 47 |
+
asset = await container.assets.get_owned(
|
| 48 |
+
workspace_id=context.workspace_id,
|
| 49 |
+
request_id=request_id,
|
| 50 |
+
filename=filename,
|
| 51 |
+
)
|
| 52 |
+
path = container.cleanup.resolve_download(request_id, asset.filename)
|
| 53 |
+
await container.assets.verify_file(asset, path)
|
| 54 |
+
except CanonicalAssetNotFoundError as exc:
|
| 55 |
+
raise NotFoundError("Output file not found") from exc
|
| 56 |
+
else:
|
| 57 |
+
path = container.cleanup.resolve_download(request_id, filename)
|
| 58 |
media_type = container.validator.infer_mime(path)
|
| 59 |
headers = {
|
| 60 |
"Content-Disposition": f'attachment; filename="{path.name}"',
|
app/api/social.py
CHANGED
|
@@ -1,24 +1,42 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from typing import Annotated
|
| 4 |
|
| 5 |
-
from fastapi import APIRouter, Header, Query, Request, Response, status
|
| 6 |
|
| 7 |
from app.social.schemas.accounts import (
|
| 8 |
SocialAccountConnectRequest,
|
| 9 |
SocialAccountSelectionRequest,
|
| 10 |
SocialAccountView,
|
| 11 |
SocialConnectResponse,
|
| 12 |
-
SocialPublishOptionsView,
|
| 13 |
SocialProviderView,
|
|
|
|
| 14 |
)
|
| 15 |
from app.social.schemas.assets import (
|
| 16 |
SocialMediaAssetRegister,
|
| 17 |
SocialMediaAssetView,
|
| 18 |
)
|
| 19 |
from app.social.schemas.jobs import SocialJobView
|
| 20 |
-
from app.social.schemas.
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/v1/social", tags=["social automation"])
|
| 24 |
|
|
@@ -31,9 +49,11 @@ def _social(request: Request):
|
|
| 31 |
|
| 32 |
def _identity(request: Request) -> tuple[str, str]:
|
| 33 |
context = request.state.auth
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
async def _audit(
|
|
@@ -45,11 +65,11 @@ async def _audit(
|
|
| 45 |
post_id: str | None = None,
|
| 46 |
job_id: str | None = None,
|
| 47 |
) -> None:
|
| 48 |
-
workspace_id,
|
| 49 |
await request.app.state.container.social.audit.record(
|
| 50 |
workspace_id=workspace_id,
|
| 51 |
event_type=event_type,
|
| 52 |
-
api_key_id=
|
| 53 |
request_id=request.state.request_id,
|
| 54 |
provider=provider,
|
| 55 |
social_account_id=account_id,
|
|
@@ -63,9 +83,7 @@ async def list_providers(request: Request) -> list[SocialProviderView]:
|
|
| 63 |
return request.app.state.container.social.accounts.list_providers()
|
| 64 |
|
| 65 |
|
| 66 |
-
@router.get(
|
| 67 |
-
"/providers/{provider}/capabilities", response_model=SocialProviderView
|
| 68 |
-
)
|
| 69 |
async def provider_capabilities(request: Request, provider: str) -> SocialProviderView:
|
| 70 |
return request.app.state.container.social.accounts.get_provider(provider)
|
| 71 |
|
|
@@ -95,9 +113,7 @@ async def list_accounts(
|
|
| 95 |
limit: int = Query(default=100, ge=1, le=500),
|
| 96 |
) -> list[SocialAccountView]:
|
| 97 |
workspace_id, _ = _identity(request)
|
| 98 |
-
return await _social(request).accounts.list(
|
| 99 |
-
workspace_id, offset=offset, limit=limit
|
| 100 |
-
)
|
| 101 |
|
| 102 |
|
| 103 |
@router.post("/accounts/select", response_model=list[SocialAccountView])
|
|
@@ -105,9 +121,7 @@ async def select_discovered_accounts(
|
|
| 105 |
request: Request, payload: SocialAccountSelectionRequest
|
| 106 |
) -> list[SocialAccountView]:
|
| 107 |
workspace_id, _ = _identity(request)
|
| 108 |
-
selected = await _social(request).accounts.select_discovered(
|
| 109 |
-
workspace_id, payload.account_ids
|
| 110 |
-
)
|
| 111 |
for account in selected:
|
| 112 |
await _audit(
|
| 113 |
request,
|
|
@@ -132,14 +146,10 @@ async def get_account_publish_options(
|
|
| 132 |
request: Request, account_id: str
|
| 133 |
) -> SocialPublishOptionsView:
|
| 134 |
workspace_id, _ = _identity(request)
|
| 135 |
-
return await _social(request).publishing.publish_options(
|
| 136 |
-
workspace_id, account_id
|
| 137 |
-
)
|
| 138 |
|
| 139 |
|
| 140 |
-
@router.post(
|
| 141 |
-
"/accounts/{provider}/connect", response_model=SocialConnectResponse
|
| 142 |
-
)
|
| 143 |
async def connect_account(
|
| 144 |
request: Request, provider: str, payload: SocialAccountConnectRequest
|
| 145 |
) -> SocialConnectResponse:
|
|
@@ -162,9 +172,7 @@ async def connect_account(
|
|
| 162 |
async def oauth_callback(
|
| 163 |
request: Request,
|
| 164 |
provider: str,
|
| 165 |
-
state: Annotated[
|
| 166 |
-
str, Query(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9_-]+$")
|
| 167 |
-
],
|
| 168 |
code: Annotated[str | None, Query(min_length=1, max_length=4096)] = None,
|
| 169 |
error: Annotated[str | None, Query(max_length=128)] = None,
|
| 170 |
) -> SocialAccountView:
|
|
@@ -181,9 +189,7 @@ async def oauth_callback(
|
|
| 181 |
@router.post("/accounts/{account_id}/refresh", response_model=SocialAccountView)
|
| 182 |
async def refresh_account(request: Request, account_id: str) -> SocialAccountView:
|
| 183 |
workspace_id, _ = _identity(request)
|
| 184 |
-
result = await _social(request).oauth.refresh(
|
| 185 |
-
workspace_id=workspace_id, account_id=account_id
|
| 186 |
-
)
|
| 187 |
await _audit(request, "SOCIAL_ACCOUNT_REAUTHORIZED", account_id=account_id)
|
| 188 |
return result
|
| 189 |
|
|
@@ -202,9 +208,7 @@ async def disconnect_account(request: Request, account_id: str) -> Response:
|
|
| 202 |
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
| 203 |
|
| 204 |
|
| 205 |
-
@router.post(
|
| 206 |
-
"/posts", response_model=SocialPostView, status_code=status.HTTP_201_CREATED
|
| 207 |
-
)
|
| 208 |
async def create_post(
|
| 209 |
request: Request,
|
| 210 |
payload: SocialPostCreate,
|
|
@@ -220,6 +224,8 @@ async def create_post(
|
|
| 220 |
idempotency_key=idempotency_key,
|
| 221 |
)
|
| 222 |
await _audit(request, "SOCIAL_POST_CREATED", post_id=result.id)
|
|
|
|
|
|
|
| 223 |
return result
|
| 224 |
|
| 225 |
|
|
@@ -228,10 +234,18 @@ async def list_posts(
|
|
| 228 |
request: Request,
|
| 229 |
offset: int = Query(default=0, ge=0),
|
| 230 |
limit: int = Query(default=100, ge=1, le=500),
|
|
|
|
|
|
|
|
|
|
| 231 |
) -> list[SocialPostView]:
|
| 232 |
workspace_id, _ = _identity(request)
|
| 233 |
return await _social(request).publishing.list(
|
| 234 |
-
workspace_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
)
|
| 236 |
|
| 237 |
|
|
@@ -241,6 +255,76 @@ async def get_post(request: Request, post_id: str) -> SocialPostView:
|
|
| 241 |
return await _social(request).publishing.get(workspace_id, post_id)
|
| 242 |
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
@router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 245 |
async def delete_post(request: Request, post_id: str) -> Response:
|
| 246 |
workspace_id, _ = _identity(request)
|
|
@@ -263,6 +347,7 @@ async def publish_post(
|
|
| 263 |
jobs = await _social(request).publishing.queue(
|
| 264 |
workspace_id, post_id, idempotency_key=idempotency_key
|
| 265 |
)
|
|
|
|
| 266 |
return jobs
|
| 267 |
|
| 268 |
|
|
@@ -271,19 +356,134 @@ async def schedule_post(
|
|
| 271 |
request: Request, post_id: str, payload: SocialScheduleCreate
|
| 272 |
) -> SocialScheduleView:
|
| 273 |
workspace_id, _ = _identity(request)
|
| 274 |
-
await _social(request).publishing.validate_post_targets(workspace_id, post_id)
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
| 278 |
await _audit(request, "SOCIAL_SCHEDULE_CREATED", post_id=post_id)
|
|
|
|
|
|
|
| 279 |
return schedule
|
| 280 |
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
@router.post("/posts/{post_id}/cancel", response_model=SocialPostView)
|
| 283 |
async def cancel_post(request: Request, post_id: str) -> SocialPostView:
|
| 284 |
workspace_id, _ = _identity(request)
|
| 285 |
result = await _social(request).publishing.cancel(workspace_id, post_id)
|
| 286 |
await _audit(request, "SOCIAL_POST_CANCELLED", post_id=post_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
return result
|
| 288 |
|
| 289 |
|
|
@@ -294,9 +494,7 @@ async def list_jobs(
|
|
| 294 |
limit: int = Query(default=100, ge=1, le=500),
|
| 295 |
) -> list[SocialJobView]:
|
| 296 |
workspace_id, _ = _identity(request)
|
| 297 |
-
return await _social(request).jobs.list(
|
| 298 |
-
workspace_id, offset=offset, limit=limit
|
| 299 |
-
)
|
| 300 |
|
| 301 |
|
| 302 |
@router.get("/jobs/{job_id}", response_model=SocialJobView)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from datetime import datetime
|
| 4 |
from typing import Annotated
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, Header, HTTPException, Query, Request, Response, status
|
| 7 |
|
| 8 |
from app.social.schemas.accounts import (
|
| 9 |
SocialAccountConnectRequest,
|
| 10 |
SocialAccountSelectionRequest,
|
| 11 |
SocialAccountView,
|
| 12 |
SocialConnectResponse,
|
|
|
|
| 13 |
SocialProviderView,
|
| 14 |
+
SocialPublishOptionsView,
|
| 15 |
)
|
| 16 |
from app.social.schemas.assets import (
|
| 17 |
SocialMediaAssetRegister,
|
| 18 |
SocialMediaAssetView,
|
| 19 |
)
|
| 20 |
from app.social.schemas.jobs import SocialJobView
|
| 21 |
+
from app.social.schemas.operations import (
|
| 22 |
+
PublishingBatchView,
|
| 23 |
+
PublishingBulkRequest,
|
| 24 |
+
PublishingCalendarView,
|
| 25 |
+
PublishingContextView,
|
| 26 |
+
PublishingQueueView,
|
| 27 |
+
)
|
| 28 |
+
from app.social.schemas.posts import (
|
| 29 |
+
SocialPostCreate,
|
| 30 |
+
SocialPostDuplicateRequest,
|
| 31 |
+
SocialPostPatch,
|
| 32 |
+
SocialPostValidation,
|
| 33 |
+
SocialPostView,
|
| 34 |
+
)
|
| 35 |
+
from app.social.schemas.scheduling import (
|
| 36 |
+
SocialRescheduleRequest,
|
| 37 |
+
SocialScheduleCreate,
|
| 38 |
+
SocialScheduleView,
|
| 39 |
+
)
|
| 40 |
|
| 41 |
router = APIRouter(prefix="/v1/social", tags=["social automation"])
|
| 42 |
|
|
|
|
| 49 |
|
| 50 |
def _identity(request: Request) -> tuple[str, str]:
|
| 51 |
context = request.state.auth
|
| 52 |
+
if not context.workspace_id or not context.user_id:
|
| 53 |
+
# Tenant resolution is performed by APIKeyService after credential
|
| 54 |
+
# verification. Never fall back to treating an API-key ID as a tenant.
|
| 55 |
+
raise HTTPException(status_code=403, detail="No active workspace membership.")
|
| 56 |
+
return context.workspace_id, context.user_id
|
| 57 |
|
| 58 |
|
| 59 |
async def _audit(
|
|
|
|
| 65 |
post_id: str | None = None,
|
| 66 |
job_id: str | None = None,
|
| 67 |
) -> None:
|
| 68 |
+
workspace_id, _ = _identity(request)
|
| 69 |
await request.app.state.container.social.audit.record(
|
| 70 |
workspace_id=workspace_id,
|
| 71 |
event_type=event_type,
|
| 72 |
+
api_key_id=request.state.auth.api_key_id,
|
| 73 |
request_id=request.state.request_id,
|
| 74 |
provider=provider,
|
| 75 |
social_account_id=account_id,
|
|
|
|
| 83 |
return request.app.state.container.social.accounts.list_providers()
|
| 84 |
|
| 85 |
|
| 86 |
+
@router.get("/providers/{provider}/capabilities", response_model=SocialProviderView)
|
|
|
|
|
|
|
| 87 |
async def provider_capabilities(request: Request, provider: str) -> SocialProviderView:
|
| 88 |
return request.app.state.container.social.accounts.get_provider(provider)
|
| 89 |
|
|
|
|
| 113 |
limit: int = Query(default=100, ge=1, le=500),
|
| 114 |
) -> list[SocialAccountView]:
|
| 115 |
workspace_id, _ = _identity(request)
|
| 116 |
+
return await _social(request).accounts.list(workspace_id, offset=offset, limit=limit)
|
|
|
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
@router.post("/accounts/select", response_model=list[SocialAccountView])
|
|
|
|
| 121 |
request: Request, payload: SocialAccountSelectionRequest
|
| 122 |
) -> list[SocialAccountView]:
|
| 123 |
workspace_id, _ = _identity(request)
|
| 124 |
+
selected = await _social(request).accounts.select_discovered(workspace_id, payload.account_ids)
|
|
|
|
|
|
|
| 125 |
for account in selected:
|
| 126 |
await _audit(
|
| 127 |
request,
|
|
|
|
| 146 |
request: Request, account_id: str
|
| 147 |
) -> SocialPublishOptionsView:
|
| 148 |
workspace_id, _ = _identity(request)
|
| 149 |
+
return await _social(request).publishing.publish_options(workspace_id, account_id)
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
+
@router.post("/accounts/{provider}/connect", response_model=SocialConnectResponse)
|
|
|
|
|
|
|
| 153 |
async def connect_account(
|
| 154 |
request: Request, provider: str, payload: SocialAccountConnectRequest
|
| 155 |
) -> SocialConnectResponse:
|
|
|
|
| 172 |
async def oauth_callback(
|
| 173 |
request: Request,
|
| 174 |
provider: str,
|
| 175 |
+
state: Annotated[str, Query(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9_-]+$")],
|
|
|
|
|
|
|
| 176 |
code: Annotated[str | None, Query(min_length=1, max_length=4096)] = None,
|
| 177 |
error: Annotated[str | None, Query(max_length=128)] = None,
|
| 178 |
) -> SocialAccountView:
|
|
|
|
| 189 |
@router.post("/accounts/{account_id}/refresh", response_model=SocialAccountView)
|
| 190 |
async def refresh_account(request: Request, account_id: str) -> SocialAccountView:
|
| 191 |
workspace_id, _ = _identity(request)
|
| 192 |
+
result = await _social(request).oauth.refresh(workspace_id=workspace_id, account_id=account_id)
|
|
|
|
|
|
|
| 193 |
await _audit(request, "SOCIAL_ACCOUNT_REAUTHORIZED", account_id=account_id)
|
| 194 |
return result
|
| 195 |
|
|
|
|
| 208 |
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
| 209 |
|
| 210 |
|
| 211 |
+
@router.post("/posts", response_model=SocialPostView, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
|
|
| 212 |
async def create_post(
|
| 213 |
request: Request,
|
| 214 |
payload: SocialPostCreate,
|
|
|
|
| 224 |
idempotency_key=idempotency_key,
|
| 225 |
)
|
| 226 |
await _audit(request, "SOCIAL_POST_CREATED", post_id=result.id)
|
| 227 |
+
if result.publish_mode.value == "draft":
|
| 228 |
+
await _audit(request, "publishing.draft_created", post_id=result.id)
|
| 229 |
return result
|
| 230 |
|
| 231 |
|
|
|
|
| 234 |
request: Request,
|
| 235 |
offset: int = Query(default=0, ge=0),
|
| 236 |
limit: int = Query(default=100, ge=1, le=500),
|
| 237 |
+
post_status: str | None = Query(default=None, alias="status", max_length=32),
|
| 238 |
+
project_id: str | None = Query(default=None, max_length=36),
|
| 239 |
+
search: str | None = Query(default=None, max_length=200),
|
| 240 |
) -> list[SocialPostView]:
|
| 241 |
workspace_id, _ = _identity(request)
|
| 242 |
return await _social(request).publishing.list(
|
| 243 |
+
workspace_id,
|
| 244 |
+
offset=offset,
|
| 245 |
+
limit=limit,
|
| 246 |
+
status=post_status,
|
| 247 |
+
project_id=project_id,
|
| 248 |
+
search=search,
|
| 249 |
)
|
| 250 |
|
| 251 |
|
|
|
|
| 255 |
return await _social(request).publishing.get(workspace_id, post_id)
|
| 256 |
|
| 257 |
|
| 258 |
+
@router.patch("/posts/{post_id}", response_model=SocialPostView)
|
| 259 |
+
async def update_post(request: Request, post_id: str, payload: SocialPostPatch) -> SocialPostView:
|
| 260 |
+
workspace_id, _ = _identity(request)
|
| 261 |
+
result = await _social(request).operations.update_draft(workspace_id, post_id, payload)
|
| 262 |
+
await _audit(request, "publishing.draft_updated", post_id=post_id)
|
| 263 |
+
return result
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@router.get("/drafts", response_model=list[SocialPostView])
|
| 267 |
+
async def list_drafts(
|
| 268 |
+
request: Request,
|
| 269 |
+
offset: int = Query(default=0, ge=0),
|
| 270 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 271 |
+
search: str | None = Query(default=None, max_length=200),
|
| 272 |
+
) -> list[SocialPostView]:
|
| 273 |
+
workspace_id, _ = _identity(request)
|
| 274 |
+
posts = await _social(request).publishing.list(
|
| 275 |
+
workspace_id,
|
| 276 |
+
offset=offset,
|
| 277 |
+
limit=limit,
|
| 278 |
+
search=search,
|
| 279 |
+
)
|
| 280 |
+
return [
|
| 281 |
+
post
|
| 282 |
+
for post in posts
|
| 283 |
+
if post.status.value in {"draft", "ready", "failed"} and post.publish_mode.value == "draft"
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@router.patch("/drafts/{post_id}", response_model=SocialPostView)
|
| 288 |
+
async def update_draft(request: Request, post_id: str, payload: SocialPostPatch) -> SocialPostView:
|
| 289 |
+
return await update_post(request, post_id, payload)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@router.delete("/drafts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 293 |
+
async def delete_draft(request: Request, post_id: str) -> Response:
|
| 294 |
+
workspace_id, _ = _identity(request)
|
| 295 |
+
await _social(request).operations.delete_draft(workspace_id, post_id)
|
| 296 |
+
await _audit(request, "publishing.draft_deleted", post_id=post_id)
|
| 297 |
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
@router.post("/posts/{post_id}/duplicate", response_model=SocialPostView)
|
| 301 |
+
async def duplicate_post(
|
| 302 |
+
request: Request,
|
| 303 |
+
post_id: str,
|
| 304 |
+
payload: SocialPostDuplicateRequest,
|
| 305 |
+
idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255),
|
| 306 |
+
) -> SocialPostView:
|
| 307 |
+
workspace_id, user_id = _identity(request)
|
| 308 |
+
result = await _social(request).operations.duplicate(
|
| 309 |
+
workspace_id,
|
| 310 |
+
user_id,
|
| 311 |
+
post_id,
|
| 312 |
+
payload,
|
| 313 |
+
idempotency_key=idempotency_key,
|
| 314 |
+
)
|
| 315 |
+
await _audit(request, "publishing.duplicated", post_id=result.id)
|
| 316 |
+
return result
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@router.post("/posts/{post_id}/validate", response_model=SocialPostValidation)
|
| 320 |
+
async def validate_post(request: Request, post_id: str) -> SocialPostValidation:
|
| 321 |
+
workspace_id, _ = _identity(request)
|
| 322 |
+
result = await _social(request).publishing.validate_post_targets(workspace_id, post_id)
|
| 323 |
+
await _audit(request, "SOCIAL_POST_VALIDATED", post_id=post_id)
|
| 324 |
+
await _audit(request, "publishing.validated", post_id=post_id)
|
| 325 |
+
return result
|
| 326 |
+
|
| 327 |
+
|
| 328 |
@router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 329 |
async def delete_post(request: Request, post_id: str) -> Response:
|
| 330 |
workspace_id, _ = _identity(request)
|
|
|
|
| 347 |
jobs = await _social(request).publishing.queue(
|
| 348 |
workspace_id, post_id, idempotency_key=idempotency_key
|
| 349 |
)
|
| 350 |
+
await _audit(request, "SOCIAL_POST_PUBLISH_STARTED", post_id=post_id)
|
| 351 |
return jobs
|
| 352 |
|
| 353 |
|
|
|
|
| 356 |
request: Request, post_id: str, payload: SocialScheduleCreate
|
| 357 |
) -> SocialScheduleView:
|
| 358 |
workspace_id, _ = _identity(request)
|
| 359 |
+
validation = await _social(request).publishing.validate_post_targets(workspace_id, post_id)
|
| 360 |
+
if not validation.valid:
|
| 361 |
+
raise HTTPException(
|
| 362 |
+
status_code=422,
|
| 363 |
+
detail=validation.model_dump(mode="json"),
|
| 364 |
+
)
|
| 365 |
+
schedule = await _social(request).scheduling.schedule(workspace_id, post_id, payload)
|
| 366 |
await _audit(request, "SOCIAL_SCHEDULE_CREATED", post_id=post_id)
|
| 367 |
+
await _audit(request, "SOCIAL_POST_SCHEDULED", post_id=post_id)
|
| 368 |
+
await _audit(request, "publishing.scheduled", post_id=post_id)
|
| 369 |
return schedule
|
| 370 |
|
| 371 |
|
| 372 |
+
@router.post("/posts/{post_id}/reschedule", response_model=SocialScheduleView)
|
| 373 |
+
async def reschedule_post(
|
| 374 |
+
request: Request, post_id: str, payload: SocialRescheduleRequest
|
| 375 |
+
) -> SocialScheduleView:
|
| 376 |
+
workspace_id, _ = _identity(request)
|
| 377 |
+
result = await _social(request).operations.reschedule(workspace_id, post_id, payload)
|
| 378 |
+
await _audit(request, "publishing.rescheduled", post_id=post_id)
|
| 379 |
+
return result
|
| 380 |
+
|
| 381 |
+
|
| 382 |
@router.post("/posts/{post_id}/cancel", response_model=SocialPostView)
|
| 383 |
async def cancel_post(request: Request, post_id: str) -> SocialPostView:
|
| 384 |
workspace_id, _ = _identity(request)
|
| 385 |
result = await _social(request).publishing.cancel(workspace_id, post_id)
|
| 386 |
await _audit(request, "SOCIAL_POST_CANCELLED", post_id=post_id)
|
| 387 |
+
await _audit(request, "publishing.cancelled", post_id=post_id)
|
| 388 |
+
return result
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@router.get("/calendar", response_model=PublishingCalendarView)
|
| 392 |
+
async def publishing_calendar(
|
| 393 |
+
request: Request,
|
| 394 |
+
starts_at: datetime = Query(),
|
| 395 |
+
ends_at: datetime = Query(),
|
| 396 |
+
offset: int = Query(default=0, ge=0),
|
| 397 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 398 |
+
) -> PublishingCalendarView:
|
| 399 |
+
workspace_id, _ = _identity(request)
|
| 400 |
+
return await _social(request).operations.calendar(
|
| 401 |
+
workspace_id,
|
| 402 |
+
starts_at=starts_at,
|
| 403 |
+
ends_at=ends_at,
|
| 404 |
+
offset=offset,
|
| 405 |
+
limit=limit,
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@router.get("/publishing-context", response_model=PublishingContextView)
|
| 410 |
+
async def publishing_context(request: Request) -> PublishingContextView:
|
| 411 |
+
workspace_id, _ = _identity(request)
|
| 412 |
+
return PublishingContextView(
|
| 413 |
+
timezone=await _social(request).operations.workspace_timezone(workspace_id)
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
@router.get("/queue", response_model=PublishingQueueView)
|
| 418 |
+
async def publishing_queue(
|
| 419 |
+
request: Request,
|
| 420 |
+
offset: int = Query(default=0, ge=0),
|
| 421 |
+
limit: int = Query(default=50, ge=1, le=200),
|
| 422 |
+
queue_status: str | None = Query(default=None, alias="status", max_length=32),
|
| 423 |
+
provider: str | None = Query(default=None, max_length=32),
|
| 424 |
+
account_id: str | None = Query(default=None, max_length=120),
|
| 425 |
+
project_id: str | None = Query(default=None, max_length=36),
|
| 426 |
+
search: str | None = Query(default=None, max_length=200),
|
| 427 |
+
) -> PublishingQueueView:
|
| 428 |
+
workspace_id, _ = _identity(request)
|
| 429 |
+
return await _social(request).operations.queue(
|
| 430 |
+
workspace_id,
|
| 431 |
+
offset=offset,
|
| 432 |
+
limit=limit,
|
| 433 |
+
status=queue_status,
|
| 434 |
+
provider=provider,
|
| 435 |
+
account_id=account_id,
|
| 436 |
+
project_id=project_id,
|
| 437 |
+
search=search,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
@router.post(
|
| 442 |
+
"/bulk",
|
| 443 |
+
response_model=PublishingBatchView,
|
| 444 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 445 |
+
)
|
| 446 |
+
async def create_publishing_batch(
|
| 447 |
+
request: Request,
|
| 448 |
+
payload: PublishingBulkRequest,
|
| 449 |
+
idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255),
|
| 450 |
+
) -> PublishingBatchView:
|
| 451 |
+
workspace_id, user_id = _identity(request)
|
| 452 |
+
result = await _social(request).operations.create_batch(
|
| 453 |
+
workspace_id,
|
| 454 |
+
user_id,
|
| 455 |
+
payload,
|
| 456 |
+
idempotency_key=idempotency_key,
|
| 457 |
+
)
|
| 458 |
+
await _audit(request, "publishing.bulk_started")
|
| 459 |
+
return result
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
@router.post(
|
| 463 |
+
"/posts/{post_id}/targets/{target_id}/retry",
|
| 464 |
+
response_model=SocialJobView,
|
| 465 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 466 |
+
)
|
| 467 |
+
async def retry_post_target(
|
| 468 |
+
request: Request,
|
| 469 |
+
post_id: str,
|
| 470 |
+
target_id: str,
|
| 471 |
+
idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255),
|
| 472 |
+
) -> SocialJobView:
|
| 473 |
+
workspace_id, _ = _identity(request)
|
| 474 |
+
result = await _social(request).publishing.retry_target(
|
| 475 |
+
workspace_id,
|
| 476 |
+
post_id,
|
| 477 |
+
target_id,
|
| 478 |
+
idempotency_key=idempotency_key,
|
| 479 |
+
)
|
| 480 |
+
await _audit(
|
| 481 |
+
request,
|
| 482 |
+
"SOCIAL_TARGET_RETRY_QUEUED",
|
| 483 |
+
provider=result.provider.value if result.provider else None,
|
| 484 |
+
post_id=post_id,
|
| 485 |
+
job_id=result.id,
|
| 486 |
+
)
|
| 487 |
return result
|
| 488 |
|
| 489 |
|
|
|
|
| 494 |
limit: int = Query(default=100, ge=1, le=500),
|
| 495 |
) -> list[SocialJobView]:
|
| 496 |
workspace_id, _ = _identity(request)
|
| 497 |
+
return await _social(request).jobs.list(workspace_id, offset=offset, limit=limit)
|
|
|
|
|
|
|
| 498 |
|
| 499 |
|
| 500 |
@router.get("/jobs/{job_id}", response_model=SocialJobView)
|
app/brand/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Workspace-scoped brand kits and brand governance."""
|
| 2 |
+
|
app/brand/api.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from uuid import UUID
|
| 3 |
+
from fastapi import APIRouter, Request, Response, status
|
| 4 |
+
from app.brand.schemas import BrandKitCreate, BrandKitResponse
|
| 5 |
+
from app.security.errors import ForbiddenError
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/v1/brand", tags=["brand"])
|
| 8 |
+
|
| 9 |
+
def _identity(request: Request) -> tuple[str, str]:
|
| 10 |
+
context = request.state.auth
|
| 11 |
+
if not context.workspace_id or not context.user_id:
|
| 12 |
+
raise ForbiddenError
|
| 13 |
+
return context.workspace_id, context.user_id
|
| 14 |
+
|
| 15 |
+
@router.post("", response_model=BrandKitResponse, status_code=status.HTTP_201_CREATED)
|
| 16 |
+
async def create_brand_kit(request: Request, payload: BrandKitCreate) -> BrandKitResponse:
|
| 17 |
+
workspace_id, user_id = _identity(request)
|
| 18 |
+
kit, version = await request.app.state.container.brand_kits.create_brand_kit(
|
| 19 |
+
workspace_id=workspace_id,
|
| 20 |
+
name=payload.name,
|
| 21 |
+
description=payload.description,
|
| 22 |
+
user_id=user_id,
|
| 23 |
+
)
|
| 24 |
+
latest = await request.app.state.container.brand_kits.get_brand_kit_version(
|
| 25 |
+
workspace_id=workspace_id,
|
| 26 |
+
brand_kit_id=kit.id,
|
| 27 |
+
user_id=user_id,
|
| 28 |
+
)
|
| 29 |
+
return BrandKitResponse(
|
| 30 |
+
id=kit.id,
|
| 31 |
+
name=kit.name,
|
| 32 |
+
description=kit.description,
|
| 33 |
+
status=kit.status,
|
| 34 |
+
is_default=kit.is_default,
|
| 35 |
+
active_version_id=latest.id,
|
| 36 |
+
created_at=kit.created_at.isoformat(),
|
| 37 |
+
updated_at=kit.updated_at.isoformat(),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
@router.get("", response_model=list[BrandKitResponse])
|
| 41 |
+
async def list_brand_kits(request: Request) -> list[BrandKitResponse]:
|
| 42 |
+
workspace_id, user_id = _identity(request)
|
| 43 |
+
kits = await request.app.state.container.brand_kits.list_brand_kits(
|
| 44 |
+
workspace_id=workspace_id,
|
| 45 |
+
user_id=user_id,
|
| 46 |
+
)
|
| 47 |
+
response: list[BrandKitResponse] = []
|
| 48 |
+
for kit in kits:
|
| 49 |
+
try:
|
| 50 |
+
latest = await request.app.state.container.brand_kits.get_brand_kit_version(
|
| 51 |
+
workspace_id=workspace_id,
|
| 52 |
+
brand_kit_id=kit.id,
|
| 53 |
+
user_id=user_id,
|
| 54 |
+
)
|
| 55 |
+
active_version_id = latest.id
|
| 56 |
+
except Exception:
|
| 57 |
+
active_version_id = kit.active_version_id
|
| 58 |
+
response.append(BrandKitResponse(
|
| 59 |
+
id=kit.id,
|
| 60 |
+
name=kit.name,
|
| 61 |
+
description=kit.description,
|
| 62 |
+
status=kit.status,
|
| 63 |
+
is_default=kit.is_default,
|
| 64 |
+
active_version_id=active_version_id,
|
| 65 |
+
created_at=kit.created_at.isoformat(),
|
| 66 |
+
updated_at=kit.updated_at.isoformat(),
|
| 67 |
+
))
|
| 68 |
+
return response
|
| 69 |
+
|
| 70 |
+
@router.patch("/{brand_kit_id}", response_model=BrandKitResponse)
|
| 71 |
+
async def update_brand_kit(request: Request, brand_kit_id: UUID, payload: BrandKitCreate) -> BrandKitResponse:
|
| 72 |
+
workspace_id, user_id = _identity(request)
|
| 73 |
+
version = await request.app.state.container.brand_kits.update_brand_kit(
|
| 74 |
+
workspace_id=workspace_id,
|
| 75 |
+
brand_kit_id=str(brand_kit_id),
|
| 76 |
+
user_id=user_id,
|
| 77 |
+
data=payload.initial_version,
|
| 78 |
+
)
|
| 79 |
+
kit = await request.app.state.container.brand_kits.get_brand_kit(workspace_id, str(brand_kit_id), user_id=user_id)
|
| 80 |
+
return BrandKitResponse(
|
| 81 |
+
id=kit.id,
|
| 82 |
+
name=kit.name,
|
| 83 |
+
description=kit.description,
|
| 84 |
+
status=kit.status,
|
| 85 |
+
is_default=kit.is_default,
|
| 86 |
+
active_version_id=version.id,
|
| 87 |
+
created_at=kit.created_at.isoformat(),
|
| 88 |
+
updated_at=kit.updated_at.isoformat(),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
@router.delete("/{brand_kit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 92 |
+
async def delete_brand_kit(request: Request, brand_kit_id: UUID) -> Response:
|
| 93 |
+
workspace_id, user_id = _identity(request)
|
| 94 |
+
await request.app.state.container.brand_kits.delete_brand_kit(
|
| 95 |
+
workspace_id=workspace_id,
|
| 96 |
+
brand_kit_id=str(brand_kit_id),
|
| 97 |
+
user_id=user_id,
|
| 98 |
+
)
|
| 99 |
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
app/brand/capabilities.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.brand.schemas import BrandCapabilities
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def brand_capabilities(*, watermark_rendering: bool = False) -> BrandCapabilities:
|
| 5 |
+
return BrandCapabilities(
|
| 6 |
+
watermark_rendering="available" if watermark_rendering else "configuration_only",
|
| 7 |
+
supported_asset_roles=[
|
| 8 |
+
"logo_primary",
|
| 9 |
+
"logo_secondary",
|
| 10 |
+
"watermark",
|
| 11 |
+
"favicon",
|
| 12 |
+
"social_avatar",
|
| 13 |
+
"social_cover",
|
| 14 |
+
],
|
| 15 |
+
supported_providers=[
|
| 16 |
+
"facebook",
|
| 17 |
+
"instagram",
|
| 18 |
+
"tiktok",
|
| 19 |
+
"x",
|
| 20 |
+
"youtube",
|
| 21 |
+
"linkedin",
|
| 22 |
+
"telegram",
|
| 23 |
+
"whatsapp",
|
| 24 |
+
],
|
| 25 |
+
)
|
| 26 |
+
|
app/brand/errors.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.exceptions import MediaAPIError
|
| 2 |
+
|
| 3 |
+
class BrandKitNotFoundError(MediaAPIError):
|
| 4 |
+
code = "BRAND_KIT_NOT_FOUND"
|
| 5 |
+
status_code = 404
|
| 6 |
+
|
| 7 |
+
class BrandKitValidationError(MediaAPIError):
|
| 8 |
+
code = "INVALID_BRAND_KIT"
|
| 9 |
+
status_code = 422
|
app/brand/models.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import (
|
| 7 |
+
JSON,
|
| 8 |
+
Boolean,
|
| 9 |
+
CheckConstraint,
|
| 10 |
+
DateTime,
|
| 11 |
+
ForeignKey,
|
| 12 |
+
Index,
|
| 13 |
+
Integer,
|
| 14 |
+
Numeric,
|
| 15 |
+
String,
|
| 16 |
+
Text,
|
| 17 |
+
UniqueConstraint,
|
| 18 |
+
)
|
| 19 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 20 |
+
|
| 21 |
+
from app.security.models import Base, utcnow
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class BrandKit(Base):
|
| 25 |
+
__tablename__ = "brand_kits"
|
| 26 |
+
__table_args__ = (
|
| 27 |
+
CheckConstraint("status in ('active','archived')", name="ck_brand_kits_status"),
|
| 28 |
+
Index("ix_brand_kits_workspace_updated", "workspace_id", "updated_at"),
|
| 29 |
+
Index("ix_brand_kits_workspace_default", "workspace_id", "is_default"),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 33 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 34 |
+
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 35 |
+
)
|
| 36 |
+
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
| 37 |
+
description: Mapped[str | None] = mapped_column(Text)
|
| 38 |
+
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
| 39 |
+
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
| 40 |
+
active_version_id: Mapped[str | None] = mapped_column(String(36))
|
| 41 |
+
created_by: Mapped[str] = mapped_column(
|
| 42 |
+
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
| 43 |
+
)
|
| 44 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 45 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 46 |
+
)
|
| 47 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 48 |
+
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class BrandKitVersion(Base):
|
| 53 |
+
__tablename__ = "brand_kit_versions"
|
| 54 |
+
__table_args__ = (
|
| 55 |
+
UniqueConstraint("brand_kit_id", "version_number", name="uq_brand_kit_version"),
|
| 56 |
+
CheckConstraint("status in ('draft','published','archived')", name="ck_brand_version_status"),
|
| 57 |
+
Index("ix_brand_versions_kit_created", "brand_kit_id", "created_at"),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 61 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 62 |
+
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 63 |
+
)
|
| 64 |
+
brand_kit_id: Mapped[str] = mapped_column(
|
| 65 |
+
String(36), ForeignKey("brand_kits.id", ondelete="CASCADE"), nullable=False
|
| 66 |
+
)
|
| 67 |
+
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 68 |
+
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
|
| 69 |
+
logo_asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_assets.id", ondelete="SET NULL"))
|
| 70 |
+
favicon_asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_assets.id", ondelete="SET NULL"))
|
| 71 |
+
primary_color: Mapped[str | None] = mapped_column(String(32))
|
| 72 |
+
secondary_color: Mapped[str | None] = mapped_column(String(32))
|
| 73 |
+
accent_color: Mapped[str | None] = mapped_column(String(32))
|
| 74 |
+
background_color: Mapped[str | None] = mapped_column(String(32))
|
| 75 |
+
text_color: Mapped[str | None] = mapped_column(String(32))
|
| 76 |
+
font_family_primary: Mapped[str | None] = mapped_column(String(255))
|
| 77 |
+
font_family_secondary: Mapped[str | None] = mapped_column(String(255))
|
| 78 |
+
heading_font: Mapped[str | None] = mapped_column(String(255))
|
| 79 |
+
body_font: Mapped[str | None] = mapped_column(String(255))
|
| 80 |
+
voice_style: Mapped[str | None] = mapped_column(String(255))
|
| 81 |
+
tone: Mapped[str | None] = mapped_column(String(255))
|
| 82 |
+
default_cta: Mapped[str | None] = mapped_column(String(255))
|
| 83 |
+
default_hashtags: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=list)
|
| 84 |
+
watermark_asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_assets.id", ondelete="SET NULL"))
|
| 85 |
+
watermark_position: Mapped[str | None] = mapped_column(String(32))
|
| 86 |
+
watermark_opacity: Mapped[float | None] = mapped_column(Numeric(precision=3, scale=2))
|
| 87 |
+
metadata: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
|
| 88 |
+
created_by: Mapped[str] = mapped_column(
|
| 89 |
+
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
| 90 |
+
)
|
| 91 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 92 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 93 |
+
)
|
| 94 |
+
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class BrandKitAsset(Base):
|
| 98 |
+
__tablename__ = "brand_kit_assets"
|
| 99 |
+
__table_args__ = (
|
| 100 |
+
UniqueConstraint("brand_kit_version_id", "role", name="uq_brand_asset_role"),
|
| 101 |
+
Index("ix_brand_assets_workspace_asset", "workspace_id", "asset_id"),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 105 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 106 |
+
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 107 |
+
)
|
| 108 |
+
brand_kit_version_id: Mapped[str] = mapped_column(
|
| 109 |
+
String(36), ForeignKey("brand_kit_versions.id", ondelete="CASCADE"), nullable=False
|
| 110 |
+
)
|
| 111 |
+
asset_id: Mapped[str] = mapped_column(
|
| 112 |
+
String(36), ForeignKey("media_assets.id", ondelete="RESTRICT"), nullable=False
|
| 113 |
+
)
|
| 114 |
+
role: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 115 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 116 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class BrandKitPlatformSetting(Base):
|
| 121 |
+
__tablename__ = "brand_kit_platform_settings"
|
| 122 |
+
__table_args__ = (
|
| 123 |
+
UniqueConstraint(
|
| 124 |
+
"brand_kit_version_id", "provider", name="uq_brand_platform_setting"
|
| 125 |
+
),
|
| 126 |
+
Index("ix_brand_platform_settings_workspace", "workspace_id"),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 130 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 131 |
+
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 132 |
+
)
|
| 133 |
+
brand_kit_version_id: Mapped[str] = mapped_column(
|
| 134 |
+
String(36), ForeignKey("brand_kit_versions.id", ondelete="CASCADE"), nullable=False
|
| 135 |
+
)
|
| 136 |
+
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 137 |
+
settings_json: Mapped[dict[str, object]] = mapped_column(
|
| 138 |
+
"settings", JSON, nullable=False, default=dict
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class BrandGovernanceSetting(Base):
|
| 143 |
+
__tablename__ = "brand_governance_settings"
|
| 144 |
+
__table_args__ = (UniqueConstraint("workspace_id", name="uq_brand_governance_workspace"),)
|
| 145 |
+
|
| 146 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 147 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 148 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 149 |
+
)
|
| 150 |
+
require_brand_kit_for_publish: Mapped[bool] = mapped_column(
|
| 151 |
+
Boolean, nullable=False, default=False
|
| 152 |
+
)
|
| 153 |
+
require_published_brand_version: Mapped[bool] = mapped_column(
|
| 154 |
+
Boolean, nullable=False, default=False
|
| 155 |
+
)
|
| 156 |
+
allow_user_override_brand_defaults: Mapped[bool] = mapped_column(
|
| 157 |
+
Boolean, nullable=False, default=True
|
| 158 |
+
)
|
| 159 |
+
updated_by: Mapped[str] = mapped_column(
|
| 160 |
+
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
| 161 |
+
)
|
| 162 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 163 |
+
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
| 164 |
+
)
|
| 165 |
+
|
app/brand/models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.brand.models.brand import BrandKit, BrandKitVersion
|
| 2 |
+
|
| 3 |
+
__all__ = ["BrandKit", "BrandKitVersion"]
|
app/brand/models/brand.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from uuid import uuid4
|
| 4 |
+
from sqlalchemy import String, JSON, DateTime, Integer, Index, ForeignKey, UniqueConstraint, Text, Numeric
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
from app.security.models import Base, utcnow
|
| 7 |
+
|
| 8 |
+
class BrandKit(Base):
|
| 9 |
+
__tablename__ = "brand_kits"
|
| 10 |
+
|
| 11 |
+
__table_args__ = (
|
| 12 |
+
Index("ix_brand_kits_workspace_updated", "workspace_id", "updated_at"),
|
| 13 |
+
UniqueConstraint("workspace_id", "name", name="uq_brand_kits_workspace_name"),
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 17 |
+
workspace_id: Mapped[str] = mapped_column(String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
|
| 18 |
+
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False)
|
| 19 |
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 20 |
+
description: Mapped[str | None] = mapped_column(Text)
|
| 21 |
+
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft") # draft, published, archived
|
| 22 |
+
active_version_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("brand_kit_versions.id", ondelete="SET NULL"))
|
| 23 |
+
|
| 24 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
|
| 25 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
|
| 26 |
+
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 27 |
+
|
| 28 |
+
class BrandKitVersion(Base):
|
| 29 |
+
__tablename__ = "brand_kit_versions"
|
| 30 |
+
|
| 31 |
+
__table_args__ = (
|
| 32 |
+
UniqueConstraint("brand_kit_id", "version_number", name="uq_brand_kit_version"),
|
| 33 |
+
Index("ix_brand_versions_kit_created", "brand_kit_id", "version_number"),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 37 |
+
brand_kit_id: Mapped[str] = mapped_column(String(36), ForeignKey("brand_kits.id", ondelete="CASCADE"), nullable=False)
|
| 38 |
+
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 39 |
+
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
| 40 |
+
|
| 41 |
+
# Branding Fields
|
| 42 |
+
logo_asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_assets.id", ondelete="SET NULL"))
|
| 43 |
+
favicon_asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_assets.id", ondelete="SET NULL"))
|
| 44 |
+
primary_color: Mapped[str | None] = mapped_column(String(7))
|
| 45 |
+
secondary_color: Mapped[str | None] = mapped_column(String(7))
|
| 46 |
+
accent_color: Mapped[str | None] = mapped_column(String(7))
|
| 47 |
+
background_color: Mapped[str | None] = mapped_column(String(7))
|
| 48 |
+
text_color: Mapped[str | None] = mapped_column(String(7))
|
| 49 |
+
font_family_primary: Mapped[str | None] = mapped_column(String(100))
|
| 50 |
+
font_family_secondary: Mapped[str | None] = mapped_column(String(100))
|
| 51 |
+
heading_font: Mapped[str | None] = mapped_column(String(100))
|
| 52 |
+
body_font: Mapped[str | None] = mapped_column(String(100))
|
| 53 |
+
voice_style: Mapped[str | None] = mapped_column(String(50))
|
| 54 |
+
tone: Mapped[str | None] = mapped_column(String(50))
|
| 55 |
+
default_cta: Mapped[str | None] = mapped_column(String(100))
|
| 56 |
+
default_hashtags: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
|
| 57 |
+
watermark_asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_assets.id", ondelete="SET NULL"))
|
| 58 |
+
watermark_position: Mapped[str | None] = mapped_column(String(20))
|
| 59 |
+
watermark_opacity: Mapped[float | None] = mapped_column(Numeric(precision=3, scale=2))
|
| 60 |
+
metadata: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
|
| 61 |
+
|
| 62 |
+
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False)
|
| 63 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
|
| 64 |
+
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
app/brand/repositories/brand_repository.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from sqlalchemy import select, func
|
| 3 |
+
from app.brand.models.brand import BrandKit, BrandKitVersion
|
| 4 |
+
from app.brand.errors import BrandKitNotFoundError
|
| 5 |
+
from app.security.database import SecurityDatabase
|
| 6 |
+
from app.security.models import utcnow
|
| 7 |
+
|
| 8 |
+
class BrandKitRepository:
|
| 9 |
+
def __init__(self, database: SecurityDatabase) -> None:
|
| 10 |
+
self.database = database
|
| 11 |
+
|
| 12 |
+
def _map_data_to_version_args(self, data: dict[str, object]) -> dict[str, object]:
|
| 13 |
+
return {
|
| 14 |
+
"logo_asset_id": data.get("logo_asset_id"),
|
| 15 |
+
"favicon_asset_id": data.get("favicon_asset_id"),
|
| 16 |
+
"primary_color": data.get("primary_color"),
|
| 17 |
+
"secondary_color": data.get("secondary_color"),
|
| 18 |
+
"accent_color": data.get("accent_color"),
|
| 19 |
+
"background_color": data.get("background_color"),
|
| 20 |
+
"text_color": data.get("text_color"),
|
| 21 |
+
"font_family_primary": data.get("font_family_primary"),
|
| 22 |
+
"font_family_secondary": data.get("font_family_secondary"),
|
| 23 |
+
"heading_font": data.get("heading_font"),
|
| 24 |
+
"body_font": data.get("body_font"),
|
| 25 |
+
"voice_style": data.get("voice_style"),
|
| 26 |
+
"tone": data.get("tone"),
|
| 27 |
+
"default_cta": data.get("default_cta"),
|
| 28 |
+
"default_hashtags": data.get("default_hashtags", {}),
|
| 29 |
+
"watermark_asset_id": data.get("watermark_asset_id"),
|
| 30 |
+
"watermark_position": data.get("watermark_position"),
|
| 31 |
+
"watermark_opacity": data.get("watermark_opacity"),
|
| 32 |
+
"metadata": data.get("metadata", {}),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async def create(self, workspace_id: str, name: str, data: dict[str, object], *, user_id: str) -> tuple[BrandKit, BrandKitVersion]:
|
| 36 |
+
async with self.database.tenant_session(
|
| 37 |
+
workspace_id=workspace_id, user_id=user_id
|
| 38 |
+
) as session:
|
| 39 |
+
kit = BrandKit(workspace_id=workspace_id, name=name)
|
| 40 |
+
session.add(kit)
|
| 41 |
+
await session.flush() # Populate ID
|
| 42 |
+
|
| 43 |
+
version = BrandKitVersion(
|
| 44 |
+
brand_kit_id=kit.id,
|
| 45 |
+
version_number=1,
|
| 46 |
+
created_by=user_id,
|
| 47 |
+
**self._map_data_to_version_args(data)
|
| 48 |
+
)
|
| 49 |
+
session.add(version)
|
| 50 |
+
|
| 51 |
+
await session.commit()
|
| 52 |
+
await session.refresh(kit)
|
| 53 |
+
await session.refresh(version)
|
| 54 |
+
return kit, version
|
| 55 |
+
|
| 56 |
+
async def get(self, workspace_id: str, brand_kit_id: str, *, user_id: str) -> BrandKit:
|
| 57 |
+
async with self.database.tenant_session(
|
| 58 |
+
workspace_id=workspace_id, user_id=user_id
|
| 59 |
+
) as session:
|
| 60 |
+
kit = await session.scalar(
|
| 61 |
+
select(BrandKit).where(
|
| 62 |
+
BrandKit.id == brand_kit_id,
|
| 63 |
+
BrandKit.workspace_id == workspace_id,
|
| 64 |
+
)
|
| 65 |
+
)
|
| 66 |
+
if kit is None:
|
| 67 |
+
raise BrandKitNotFoundError(f"Brand Kit {brand_kit_id} not found.")
|
| 68 |
+
return kit
|
| 69 |
+
|
| 70 |
+
async def list(self, workspace_id: str, *, user_id: str) -> list[BrandKit]:
|
| 71 |
+
async with self.database.tenant_session(
|
| 72 |
+
workspace_id=workspace_id, user_id=user_id
|
| 73 |
+
) as session:
|
| 74 |
+
return list((await session.scalars(
|
| 75 |
+
select(BrandKit).where(BrandKit.workspace_id == workspace_id)
|
| 76 |
+
)).all())
|
| 77 |
+
|
| 78 |
+
async def get_latest_version(self, workspace_id: str, brand_kit_id: str, *, user_id: str) -> BrandKitVersion:
|
| 79 |
+
async with self.database.tenant_session(
|
| 80 |
+
workspace_id=workspace_id, user_id=user_id
|
| 81 |
+
) as session:
|
| 82 |
+
version = await session.scalar(
|
| 83 |
+
select(BrandKitVersion)
|
| 84 |
+
.join(BrandKit)
|
| 85 |
+
.where(BrandKit.id == brand_kit_id, BrandKit.workspace_id == workspace_id)
|
| 86 |
+
.order_by(BrandKitVersion.version_number.desc())
|
| 87 |
+
.limit(1)
|
| 88 |
+
)
|
| 89 |
+
if version is None:
|
| 90 |
+
raise BrandKitNotFoundError(f"No version found for Brand Kit {brand_kit_id}.")
|
| 91 |
+
return version
|
| 92 |
+
|
| 93 |
+
async def create_version(self, workspace_id: str, brand_kit_id: str, data: dict[str, object], *, user_id: str) -> BrandKitVersion:
|
| 94 |
+
async with self.database.tenant_session(
|
| 95 |
+
workspace_id=workspace_id, user_id=user_id
|
| 96 |
+
) as session:
|
| 97 |
+
# Check existence and lock
|
| 98 |
+
kit = await session.scalar(
|
| 99 |
+
select(BrandKit).where(
|
| 100 |
+
BrandKit.id == brand_kit_id,
|
| 101 |
+
BrandKit.workspace_id == workspace_id,
|
| 102 |
+
).with_for_update()
|
| 103 |
+
)
|
| 104 |
+
if kit is None:
|
| 105 |
+
raise BrandKitNotFoundError(f"Brand Kit {brand_kit_id} not found.")
|
| 106 |
+
|
| 107 |
+
# Get latest version number
|
| 108 |
+
latest_version = await session.scalar(
|
| 109 |
+
select(func.max(BrandKitVersion.version_number))
|
| 110 |
+
.where(BrandKitVersion.brand_kit_id == brand_kit_id)
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
new_version = BrandKitVersion(
|
| 114 |
+
brand_kit_id=brand_kit_id,
|
| 115 |
+
version_number=(latest_version or 0) + 1,
|
| 116 |
+
created_by=user_id,
|
| 117 |
+
**self._map_data_to_version_args(data)
|
| 118 |
+
)
|
| 119 |
+
session.add(new_version)
|
| 120 |
+
kit.updated_at = utcnow()
|
| 121 |
+
await session.commit()
|
| 122 |
+
await session.refresh(new_version)
|
| 123 |
+
return new_version
|
| 124 |
+
|
| 125 |
+
async def delete(self, workspace_id: str, brand_kit_id: str, *, user_id: str) -> None:
|
| 126 |
+
async with self.database.tenant_session(
|
| 127 |
+
workspace_id=workspace_id, user_id=user_id
|
| 128 |
+
) as session:
|
| 129 |
+
kit = await session.scalar(
|
| 130 |
+
select(BrandKit).where(
|
| 131 |
+
BrandKit.id == brand_kit_id,
|
| 132 |
+
BrandKit.workspace_id == workspace_id,
|
| 133 |
+
).with_for_update()
|
| 134 |
+
)
|
| 135 |
+
if kit is None:
|
| 136 |
+
raise BrandKitNotFoundError(f"Brand Kit {brand_kit_id} not found.")
|
| 137 |
+
await session.delete(kit)
|
| 138 |
+
await session.commit()
|
app/brand/repository.py
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import delete, func, or_, select, update
|
| 6 |
+
from sqlalchemy.exc import IntegrityError
|
| 7 |
+
|
| 8 |
+
from app.brand.errors import (
|
| 9 |
+
BrandConflictError,
|
| 10 |
+
BrandImmutableError,
|
| 11 |
+
BrandNotFoundError,
|
| 12 |
+
BrandVersionNotFoundError,
|
| 13 |
+
)
|
| 14 |
+
from app.brand.models import (
|
| 15 |
+
BrandGovernanceSetting,
|
| 16 |
+
BrandKit,
|
| 17 |
+
BrandKitAsset,
|
| 18 |
+
BrandKitPlatformSetting,
|
| 19 |
+
BrandKitVersion,
|
| 20 |
+
)
|
| 21 |
+
from app.projects.models import Project
|
| 22 |
+
from app.security.database import SecurityDatabase
|
| 23 |
+
from app.security.models import CanonicalMediaAsset
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BrandRepository:
|
| 27 |
+
def __init__(self, database: SecurityDatabase) -> None:
|
| 28 |
+
self.database = database
|
| 29 |
+
|
| 30 |
+
async def list(
|
| 31 |
+
self,
|
| 32 |
+
workspace_id: str,
|
| 33 |
+
*,
|
| 34 |
+
user_id: str,
|
| 35 |
+
search: str | None,
|
| 36 |
+
status: str,
|
| 37 |
+
offset: int,
|
| 38 |
+
limit: int,
|
| 39 |
+
) -> tuple[list[BrandKit], int]:
|
| 40 |
+
predicates = [
|
| 41 |
+
BrandKit.workspace_id == workspace_id,
|
| 42 |
+
BrandKit.status == status,
|
| 43 |
+
]
|
| 44 |
+
if search:
|
| 45 |
+
term = search.casefold()
|
| 46 |
+
predicates.append(
|
| 47 |
+
or_(
|
| 48 |
+
func.lower(BrandKit.name).contains(term, autoescape=True),
|
| 49 |
+
func.lower(BrandKit.description).contains(term, autoescape=True),
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
async with self.database.tenant_session(
|
| 53 |
+
workspace_id=workspace_id, user_id=user_id
|
| 54 |
+
) as session:
|
| 55 |
+
total = int(
|
| 56 |
+
await session.scalar(
|
| 57 |
+
select(func.count()).select_from(BrandKit).where(*predicates)
|
| 58 |
+
)
|
| 59 |
+
or 0
|
| 60 |
+
)
|
| 61 |
+
items = list(
|
| 62 |
+
(
|
| 63 |
+
await session.scalars(
|
| 64 |
+
select(BrandKit)
|
| 65 |
+
.where(*predicates)
|
| 66 |
+
.order_by(BrandKit.is_default.desc(), BrandKit.updated_at.desc())
|
| 67 |
+
.offset(offset)
|
| 68 |
+
.limit(limit)
|
| 69 |
+
)
|
| 70 |
+
).all()
|
| 71 |
+
)
|
| 72 |
+
return items, total
|
| 73 |
+
|
| 74 |
+
async def create(
|
| 75 |
+
self, kit: BrandKit, version: BrandKitVersion, *, user_id: str
|
| 76 |
+
) -> tuple[BrandKit, BrandKitVersion]:
|
| 77 |
+
async with self.database.tenant_session(
|
| 78 |
+
workspace_id=kit.workspace_id, user_id=user_id
|
| 79 |
+
) as session:
|
| 80 |
+
if kit.is_default:
|
| 81 |
+
await session.execute(
|
| 82 |
+
update(BrandKit)
|
| 83 |
+
.where(BrandKit.workspace_id == kit.workspace_id)
|
| 84 |
+
.values(is_default=False)
|
| 85 |
+
)
|
| 86 |
+
session.add(kit)
|
| 87 |
+
await session.flush()
|
| 88 |
+
version.brand_kit_id = kit.id
|
| 89 |
+
session.add(version)
|
| 90 |
+
try:
|
| 91 |
+
await session.commit()
|
| 92 |
+
except IntegrityError as exc:
|
| 93 |
+
await session.rollback()
|
| 94 |
+
raise BrandConflictError("Brand kit creation conflicted with existing data.") from exc
|
| 95 |
+
await session.refresh(kit)
|
| 96 |
+
await session.refresh(version)
|
| 97 |
+
return kit, version
|
| 98 |
+
|
| 99 |
+
async def get(
|
| 100 |
+
self, workspace_id: str, brand_kit_id: str, *, user_id: str, mutable: bool = False
|
| 101 |
+
) -> BrandKit:
|
| 102 |
+
query = select(BrandKit).where(
|
| 103 |
+
BrandKit.id == brand_kit_id,
|
| 104 |
+
BrandKit.workspace_id == workspace_id,
|
| 105 |
+
)
|
| 106 |
+
if mutable:
|
| 107 |
+
query = query.with_for_update()
|
| 108 |
+
async with self.database.tenant_session(
|
| 109 |
+
workspace_id=workspace_id, user_id=user_id
|
| 110 |
+
) as session:
|
| 111 |
+
kit = await session.scalar(query)
|
| 112 |
+
if kit is None:
|
| 113 |
+
raise BrandNotFoundError("Brand kit was not found in this workspace.")
|
| 114 |
+
return kit
|
| 115 |
+
|
| 116 |
+
async def update(
|
| 117 |
+
self,
|
| 118 |
+
workspace_id: str,
|
| 119 |
+
brand_kit_id: str,
|
| 120 |
+
*,
|
| 121 |
+
user_id: str,
|
| 122 |
+
fields: dict[str, object],
|
| 123 |
+
) -> BrandKit:
|
| 124 |
+
async with self.database.tenant_session(
|
| 125 |
+
workspace_id=workspace_id, user_id=user_id
|
| 126 |
+
) as session:
|
| 127 |
+
kit = await session.scalar(
|
| 128 |
+
select(BrandKit)
|
| 129 |
+
.where(
|
| 130 |
+
BrandKit.id == brand_kit_id,
|
| 131 |
+
BrandKit.workspace_id == workspace_id,
|
| 132 |
+
)
|
| 133 |
+
.with_for_update()
|
| 134 |
+
)
|
| 135 |
+
if kit is None:
|
| 136 |
+
raise BrandNotFoundError("Brand kit was not found in this workspace.")
|
| 137 |
+
for key, value in fields.items():
|
| 138 |
+
setattr(kit, key, value)
|
| 139 |
+
kit.updated_at = datetime.now(timezone.utc)
|
| 140 |
+
await session.commit()
|
| 141 |
+
await session.refresh(kit)
|
| 142 |
+
return kit
|
| 143 |
+
|
| 144 |
+
async def archive(
|
| 145 |
+
self, workspace_id: str, brand_kit_id: str, *, user_id: str
|
| 146 |
+
) -> BrandKit:
|
| 147 |
+
async with self.database.tenant_session(
|
| 148 |
+
workspace_id=workspace_id, user_id=user_id
|
| 149 |
+
) as session:
|
| 150 |
+
kit = await session.scalar(
|
| 151 |
+
select(BrandKit)
|
| 152 |
+
.where(
|
| 153 |
+
BrandKit.id == brand_kit_id,
|
| 154 |
+
BrandKit.workspace_id == workspace_id,
|
| 155 |
+
)
|
| 156 |
+
.with_for_update()
|
| 157 |
+
)
|
| 158 |
+
if kit is None:
|
| 159 |
+
raise BrandNotFoundError("Brand kit was not found in this workspace.")
|
| 160 |
+
if kit.is_default:
|
| 161 |
+
raise BrandConflictError("The default brand kit must be replaced before archiving.")
|
| 162 |
+
kit.status = "archived"
|
| 163 |
+
kit.updated_at = datetime.now(timezone.utc)
|
| 164 |
+
await session.commit()
|
| 165 |
+
await session.refresh(kit)
|
| 166 |
+
return kit
|
| 167 |
+
|
| 168 |
+
async def set_default(
|
| 169 |
+
self, workspace_id: str, brand_kit_id: str, *, user_id: str
|
| 170 |
+
) -> BrandKit:
|
| 171 |
+
async with self.database.tenant_session(
|
| 172 |
+
workspace_id=workspace_id, user_id=user_id
|
| 173 |
+
) as session:
|
| 174 |
+
kit = await session.scalar(
|
| 175 |
+
select(BrandKit)
|
| 176 |
+
.where(
|
| 177 |
+
BrandKit.id == brand_kit_id,
|
| 178 |
+
BrandKit.workspace_id == workspace_id,
|
| 179 |
+
BrandKit.status == "active",
|
| 180 |
+
)
|
| 181 |
+
.with_for_update()
|
| 182 |
+
)
|
| 183 |
+
if kit is None:
|
| 184 |
+
raise BrandNotFoundError("Active brand kit was not found in this workspace.")
|
| 185 |
+
await session.execute(
|
| 186 |
+
update(BrandKit)
|
| 187 |
+
.where(BrandKit.workspace_id == workspace_id)
|
| 188 |
+
.values(is_default=False)
|
| 189 |
+
)
|
| 190 |
+
kit.is_default = True
|
| 191 |
+
kit.updated_at = datetime.now(timezone.utc)
|
| 192 |
+
await session.commit()
|
| 193 |
+
await session.refresh(kit)
|
| 194 |
+
return kit
|
| 195 |
+
|
| 196 |
+
async def versions(
|
| 197 |
+
self, workspace_id: str, brand_kit_id: str, *, user_id: str
|
| 198 |
+
) -> list[BrandKitVersion]:
|
| 199 |
+
async with self.database.tenant_session(
|
| 200 |
+
workspace_id=workspace_id, user_id=user_id
|
| 201 |
+
) as session:
|
| 202 |
+
if not await session.scalar(
|
| 203 |
+
select(BrandKit.id).where(
|
| 204 |
+
BrandKit.id == brand_kit_id,
|
| 205 |
+
BrandKit.workspace_id == workspace_id,
|
| 206 |
+
)
|
| 207 |
+
):
|
| 208 |
+
raise BrandNotFoundError("Brand kit was not found in this workspace.")
|
| 209 |
+
return list(
|
| 210 |
+
(
|
| 211 |
+
await session.scalars(
|
| 212 |
+
select(BrandKitVersion)
|
| 213 |
+
.where(
|
| 214 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 215 |
+
BrandKitVersion.brand_kit_id == brand_kit_id,
|
| 216 |
+
)
|
| 217 |
+
.order_by(BrandKitVersion.version_number.desc())
|
| 218 |
+
)
|
| 219 |
+
).all()
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
async def version(
|
| 223 |
+
self,
|
| 224 |
+
workspace_id: str,
|
| 225 |
+
brand_kit_id: str,
|
| 226 |
+
version_id: str,
|
| 227 |
+
*,
|
| 228 |
+
user_id: str,
|
| 229 |
+
) -> BrandKitVersion:
|
| 230 |
+
async with self.database.tenant_session(
|
| 231 |
+
workspace_id=workspace_id, user_id=user_id
|
| 232 |
+
) as session:
|
| 233 |
+
item = await session.scalar(
|
| 234 |
+
select(BrandKitVersion).where(
|
| 235 |
+
BrandKitVersion.id == version_id,
|
| 236 |
+
BrandKitVersion.brand_kit_id == brand_kit_id,
|
| 237 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
if item is None:
|
| 241 |
+
raise BrandVersionNotFoundError(
|
| 242 |
+
"Brand kit version was not found in this workspace."
|
| 243 |
+
)
|
| 244 |
+
return item
|
| 245 |
+
|
| 246 |
+
async def create_version(
|
| 247 |
+
self, version: BrandKitVersion, *, user_id: str
|
| 248 |
+
) -> BrandKitVersion:
|
| 249 |
+
async with self.database.tenant_session(
|
| 250 |
+
workspace_id=version.workspace_id, user_id=user_id
|
| 251 |
+
) as session:
|
| 252 |
+
kit = await session.scalar(
|
| 253 |
+
select(BrandKit)
|
| 254 |
+
.where(
|
| 255 |
+
BrandKit.id == version.brand_kit_id,
|
| 256 |
+
BrandKit.workspace_id == version.workspace_id,
|
| 257 |
+
BrandKit.status == "active",
|
| 258 |
+
)
|
| 259 |
+
.with_for_update()
|
| 260 |
+
)
|
| 261 |
+
if kit is None:
|
| 262 |
+
raise BrandNotFoundError("Active brand kit was not found in this workspace.")
|
| 263 |
+
version.version_number = (
|
| 264 |
+
int(
|
| 265 |
+
await session.scalar(
|
| 266 |
+
select(func.max(BrandKitVersion.version_number)).where(
|
| 267 |
+
BrandKitVersion.brand_kit_id == version.brand_kit_id
|
| 268 |
+
)
|
| 269 |
+
)
|
| 270 |
+
or 0
|
| 271 |
+
)
|
| 272 |
+
+ 1
|
| 273 |
+
)
|
| 274 |
+
session.add(version)
|
| 275 |
+
await session.commit()
|
| 276 |
+
await session.refresh(version)
|
| 277 |
+
return version
|
| 278 |
+
|
| 279 |
+
async def update_version(
|
| 280 |
+
self,
|
| 281 |
+
workspace_id: str,
|
| 282 |
+
brand_kit_id: str,
|
| 283 |
+
version_id: str,
|
| 284 |
+
*,
|
| 285 |
+
user_id: str,
|
| 286 |
+
fields: dict[str, object],
|
| 287 |
+
) -> BrandKitVersion:
|
| 288 |
+
async with self.database.tenant_session(
|
| 289 |
+
workspace_id=workspace_id, user_id=user_id
|
| 290 |
+
) as session:
|
| 291 |
+
version = await session.scalar(
|
| 292 |
+
select(BrandKitVersion)
|
| 293 |
+
.where(
|
| 294 |
+
BrandKitVersion.id == version_id,
|
| 295 |
+
BrandKitVersion.brand_kit_id == brand_kit_id,
|
| 296 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 297 |
+
)
|
| 298 |
+
.with_for_update()
|
| 299 |
+
)
|
| 300 |
+
if version is None:
|
| 301 |
+
raise BrandVersionNotFoundError(
|
| 302 |
+
"Brand kit version was not found in this workspace."
|
| 303 |
+
)
|
| 304 |
+
if version.status != "draft":
|
| 305 |
+
raise BrandImmutableError("Published or archived brand versions are immutable.")
|
| 306 |
+
for key, value in fields.items():
|
| 307 |
+
setattr(version, key, value)
|
| 308 |
+
await session.commit()
|
| 309 |
+
await session.refresh(version)
|
| 310 |
+
return version
|
| 311 |
+
|
| 312 |
+
async def publish_version(
|
| 313 |
+
self, workspace_id: str, brand_kit_id: str, version_id: str, *, user_id: str
|
| 314 |
+
) -> tuple[BrandKit, BrandKitVersion]:
|
| 315 |
+
async with self.database.tenant_session(
|
| 316 |
+
workspace_id=workspace_id, user_id=user_id
|
| 317 |
+
) as session:
|
| 318 |
+
kit = await session.scalar(
|
| 319 |
+
select(BrandKit)
|
| 320 |
+
.where(
|
| 321 |
+
BrandKit.id == brand_kit_id,
|
| 322 |
+
BrandKit.workspace_id == workspace_id,
|
| 323 |
+
BrandKit.status == "active",
|
| 324 |
+
)
|
| 325 |
+
.with_for_update()
|
| 326 |
+
)
|
| 327 |
+
version = await session.scalar(
|
| 328 |
+
select(BrandKitVersion)
|
| 329 |
+
.where(
|
| 330 |
+
BrandKitVersion.id == version_id,
|
| 331 |
+
BrandKitVersion.brand_kit_id == brand_kit_id,
|
| 332 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 333 |
+
)
|
| 334 |
+
.with_for_update()
|
| 335 |
+
)
|
| 336 |
+
if kit is None:
|
| 337 |
+
raise BrandNotFoundError("Active brand kit was not found in this workspace.")
|
| 338 |
+
if version is None:
|
| 339 |
+
raise BrandVersionNotFoundError(
|
| 340 |
+
"Brand kit version was not found in this workspace."
|
| 341 |
+
)
|
| 342 |
+
if version.status != "draft":
|
| 343 |
+
raise BrandConflictError("Only a draft brand version can be published.")
|
| 344 |
+
now = datetime.now(timezone.utc)
|
| 345 |
+
version.status = "published"
|
| 346 |
+
version.published_at = now
|
| 347 |
+
kit.active_version_id = version.id
|
| 348 |
+
kit.updated_at = now
|
| 349 |
+
await session.commit()
|
| 350 |
+
await session.refresh(kit)
|
| 351 |
+
await session.refresh(version)
|
| 352 |
+
return kit, version
|
| 353 |
+
|
| 354 |
+
async def archive_version(
|
| 355 |
+
self, workspace_id: str, brand_kit_id: str, version_id: str, *, user_id: str
|
| 356 |
+
) -> BrandKitVersion:
|
| 357 |
+
async with self.database.tenant_session(
|
| 358 |
+
workspace_id=workspace_id, user_id=user_id
|
| 359 |
+
) as session:
|
| 360 |
+
kit = await session.scalar(
|
| 361 |
+
select(BrandKit)
|
| 362 |
+
.where(
|
| 363 |
+
BrandKit.id == brand_kit_id,
|
| 364 |
+
BrandKit.workspace_id == workspace_id,
|
| 365 |
+
)
|
| 366 |
+
.with_for_update()
|
| 367 |
+
)
|
| 368 |
+
version = await session.scalar(
|
| 369 |
+
select(BrandKitVersion)
|
| 370 |
+
.where(
|
| 371 |
+
BrandKitVersion.id == version_id,
|
| 372 |
+
BrandKitVersion.brand_kit_id == brand_kit_id,
|
| 373 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 374 |
+
)
|
| 375 |
+
.with_for_update()
|
| 376 |
+
)
|
| 377 |
+
if kit is None or version is None:
|
| 378 |
+
raise BrandVersionNotFoundError(
|
| 379 |
+
"Brand kit version was not found in this workspace."
|
| 380 |
+
)
|
| 381 |
+
if kit.active_version_id == version.id:
|
| 382 |
+
raise BrandConflictError("The active published version cannot be archived.")
|
| 383 |
+
version.status = "archived"
|
| 384 |
+
await session.commit()
|
| 385 |
+
await session.refresh(version)
|
| 386 |
+
return version
|
| 387 |
+
|
| 388 |
+
async def replace_platform_settings(
|
| 389 |
+
self,
|
| 390 |
+
workspace_id: str,
|
| 391 |
+
version_id: str,
|
| 392 |
+
settings: list[BrandKitPlatformSetting],
|
| 393 |
+
*,
|
| 394 |
+
user_id: str,
|
| 395 |
+
) -> None:
|
| 396 |
+
async with self.database.tenant_session(
|
| 397 |
+
workspace_id=workspace_id, user_id=user_id
|
| 398 |
+
) as session:
|
| 399 |
+
version = await session.scalar(
|
| 400 |
+
select(BrandKitVersion)
|
| 401 |
+
.where(
|
| 402 |
+
BrandKitVersion.id == version_id,
|
| 403 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 404 |
+
)
|
| 405 |
+
.with_for_update()
|
| 406 |
+
)
|
| 407 |
+
if version is None:
|
| 408 |
+
raise BrandVersionNotFoundError("Brand kit version was not found.")
|
| 409 |
+
if version.status != "draft":
|
| 410 |
+
raise BrandImmutableError("Published or archived brand versions are immutable.")
|
| 411 |
+
await session.execute(
|
| 412 |
+
delete(BrandKitPlatformSetting).where(
|
| 413 |
+
BrandKitPlatformSetting.brand_kit_version_id == version_id
|
| 414 |
+
)
|
| 415 |
+
)
|
| 416 |
+
session.add_all(settings)
|
| 417 |
+
await session.commit()
|
| 418 |
+
|
| 419 |
+
async def platform_settings(
|
| 420 |
+
self, workspace_id: str, version_id: str, *, user_id: str
|
| 421 |
+
) -> list[BrandKitPlatformSetting]:
|
| 422 |
+
async with self.database.tenant_session(
|
| 423 |
+
workspace_id=workspace_id, user_id=user_id
|
| 424 |
+
) as session:
|
| 425 |
+
return list(
|
| 426 |
+
(
|
| 427 |
+
await session.scalars(
|
| 428 |
+
select(BrandKitPlatformSetting).where(
|
| 429 |
+
BrandKitPlatformSetting.workspace_id == workspace_id,
|
| 430 |
+
BrandKitPlatformSetting.brand_kit_version_id == version_id,
|
| 431 |
+
)
|
| 432 |
+
)
|
| 433 |
+
).all()
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
async def assets(
|
| 437 |
+
self, workspace_id: str, version_id: str, *, user_id: str
|
| 438 |
+
) -> list[tuple[BrandKitAsset, CanonicalMediaAsset]]:
|
| 439 |
+
async with self.database.tenant_session(
|
| 440 |
+
workspace_id=workspace_id, user_id=user_id
|
| 441 |
+
) as session:
|
| 442 |
+
rows = await session.execute(
|
| 443 |
+
select(BrandKitAsset, CanonicalMediaAsset)
|
| 444 |
+
.join(CanonicalMediaAsset, CanonicalMediaAsset.id == BrandKitAsset.asset_id)
|
| 445 |
+
.where(
|
| 446 |
+
BrandKitAsset.workspace_id == workspace_id,
|
| 447 |
+
BrandKitAsset.brand_kit_version_id == version_id,
|
| 448 |
+
CanonicalMediaAsset.workspace_id == workspace_id,
|
| 449 |
+
)
|
| 450 |
+
)
|
| 451 |
+
return list(rows.all())
|
| 452 |
+
|
| 453 |
+
async def attach_asset(
|
| 454 |
+
self, association: BrandKitAsset, *, user_id: str
|
| 455 |
+
) -> BrandKitAsset:
|
| 456 |
+
async with self.database.tenant_session(
|
| 457 |
+
workspace_id=association.workspace_id, user_id=user_id
|
| 458 |
+
) as session:
|
| 459 |
+
version = await session.scalar(
|
| 460 |
+
select(BrandKitVersion)
|
| 461 |
+
.where(
|
| 462 |
+
BrandKitVersion.id == association.brand_kit_version_id,
|
| 463 |
+
BrandKitVersion.workspace_id == association.workspace_id,
|
| 464 |
+
)
|
| 465 |
+
.with_for_update()
|
| 466 |
+
)
|
| 467 |
+
if version is None:
|
| 468 |
+
raise BrandVersionNotFoundError("Brand kit version was not found.")
|
| 469 |
+
if version.status != "draft":
|
| 470 |
+
raise BrandImmutableError("Assets on a published brand version are immutable.")
|
| 471 |
+
existing = await session.scalar(
|
| 472 |
+
select(BrandKitAsset).where(
|
| 473 |
+
BrandKitAsset.brand_kit_version_id
|
| 474 |
+
== association.brand_kit_version_id,
|
| 475 |
+
BrandKitAsset.role == association.role,
|
| 476 |
+
)
|
| 477 |
+
)
|
| 478 |
+
if existing:
|
| 479 |
+
existing.asset_id = association.asset_id
|
| 480 |
+
await session.commit()
|
| 481 |
+
await session.refresh(existing)
|
| 482 |
+
return existing
|
| 483 |
+
session.add(association)
|
| 484 |
+
await session.commit()
|
| 485 |
+
await session.refresh(association)
|
| 486 |
+
return association
|
| 487 |
+
|
| 488 |
+
async def detach_asset(
|
| 489 |
+
self,
|
| 490 |
+
workspace_id: str,
|
| 491 |
+
version_id: str,
|
| 492 |
+
asset_id: str,
|
| 493 |
+
*,
|
| 494 |
+
user_id: str,
|
| 495 |
+
) -> None:
|
| 496 |
+
async with self.database.tenant_session(
|
| 497 |
+
workspace_id=workspace_id, user_id=user_id
|
| 498 |
+
) as session:
|
| 499 |
+
version = await session.scalar(
|
| 500 |
+
select(BrandKitVersion)
|
| 501 |
+
.where(
|
| 502 |
+
BrandKitVersion.id == version_id,
|
| 503 |
+
BrandKitVersion.workspace_id == workspace_id,
|
| 504 |
+
)
|
| 505 |
+
.with_for_update()
|
| 506 |
+
)
|
| 507 |
+
if version is None:
|
| 508 |
+
raise BrandVersionNotFoundError("Brand kit version was not found.")
|
| 509 |
+
if version.status != "draft":
|
| 510 |
+
raise BrandImmutableError("Assets on a published brand version are immutable.")
|
| 511 |
+
result = await session.execute(
|
| 512 |
+
delete(BrandKitAsset).where(
|
| 513 |
+
BrandKitAsset.workspace_id == workspace_id,
|
| 514 |
+
BrandKitAsset.brand_kit_version_id == version_id,
|
| 515 |
+
BrandKitAsset.asset_id == asset_id,
|
| 516 |
+
)
|
| 517 |
+
)
|
| 518 |
+
if not result.rowcount:
|
| 519 |
+
raise BrandVersionNotFoundError("Brand asset association was not found.")
|
| 520 |
+
await session.commit()
|
| 521 |
+
|
| 522 |
+
async def governance(
|
| 523 |
+
self, workspace_id: str, *, user_id: str
|
| 524 |
+
) -> BrandGovernanceSetting | None:
|
| 525 |
+
async with self.database.tenant_session(
|
| 526 |
+
workspace_id=workspace_id, user_id=user_id
|
| 527 |
+
) as session:
|
| 528 |
+
return await session.scalar(
|
| 529 |
+
select(BrandGovernanceSetting).where(
|
| 530 |
+
BrandGovernanceSetting.workspace_id == workspace_id
|
| 531 |
+
)
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
async def set_governance(
|
| 535 |
+
self,
|
| 536 |
+
setting: BrandGovernanceSetting,
|
| 537 |
+
*,
|
| 538 |
+
user_id: str,
|
| 539 |
+
) -> BrandGovernanceSetting:
|
| 540 |
+
async with self.database.tenant_session(
|
| 541 |
+
workspace_id=setting.workspace_id, user_id=user_id
|
| 542 |
+
) as session:
|
| 543 |
+
existing = await session.scalar(
|
| 544 |
+
select(BrandGovernanceSetting)
|
| 545 |
+
.where(BrandGovernanceSetting.workspace_id == setting.workspace_id)
|
| 546 |
+
.with_for_update()
|
| 547 |
+
)
|
| 548 |
+
if existing is None:
|
| 549 |
+
session.add(setting)
|
| 550 |
+
existing = setting
|
| 551 |
+
else:
|
| 552 |
+
existing.require_brand_kit_for_publish = (
|
| 553 |
+
setting.require_brand_kit_for_publish
|
| 554 |
+
)
|
| 555 |
+
existing.require_published_brand_version = (
|
| 556 |
+
setting.require_published_brand_version
|
| 557 |
+
)
|
| 558 |
+
existing.allow_user_override_brand_defaults = (
|
| 559 |
+
setting.allow_user_override_brand_defaults
|
| 560 |
+
)
|
| 561 |
+
existing.updated_by = setting.updated_by
|
| 562 |
+
existing.updated_at = datetime.now(timezone.utc)
|
| 563 |
+
await session.commit()
|
| 564 |
+
await session.refresh(existing)
|
| 565 |
+
return existing
|
| 566 |
+
|
| 567 |
+
async def apply_to_project(
|
| 568 |
+
self,
|
| 569 |
+
workspace_id: str,
|
| 570 |
+
brand_kit_id: str,
|
| 571 |
+
project_id: str,
|
| 572 |
+
*,
|
| 573 |
+
user_id: str,
|
| 574 |
+
) -> Project:
|
| 575 |
+
async with self.database.tenant_session(
|
| 576 |
+
workspace_id=workspace_id, user_id=user_id
|
| 577 |
+
) as session:
|
| 578 |
+
kit = await session.scalar(
|
| 579 |
+
select(BrandKit).where(
|
| 580 |
+
BrandKit.id == brand_kit_id,
|
| 581 |
+
BrandKit.workspace_id == workspace_id,
|
| 582 |
+
BrandKit.status == "active",
|
| 583 |
+
)
|
| 584 |
+
)
|
| 585 |
+
project = await session.scalar(
|
| 586 |
+
select(Project)
|
| 587 |
+
.where(
|
| 588 |
+
Project.id == project_id,
|
| 589 |
+
Project.workspace_id == workspace_id,
|
| 590 |
+
Project.status == "active",
|
| 591 |
+
)
|
| 592 |
+
.with_for_update()
|
| 593 |
+
)
|
| 594 |
+
if kit is None:
|
| 595 |
+
raise BrandNotFoundError("Active brand kit was not found in this workspace.")
|
| 596 |
+
if project is None:
|
| 597 |
+
raise BrandNotFoundError("Project was not found in this workspace.")
|
| 598 |
+
project.brand_kit_id = kit.id
|
| 599 |
+
project.updated_at = datetime.now(timezone.utc)
|
| 600 |
+
await session.commit()
|
| 601 |
+
await session.refresh(project)
|
| 602 |
+
return project
|
| 603 |
+
|
app/brand/schemas.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
class BrandKitCreate(BaseModel):
|
| 5 |
+
name: str = Field(..., min_length=1, max_length=255)
|
| 6 |
+
description: str | None = None
|
| 7 |
+
initial_version: dict[str, object] # For now, keep as dict for simplicity, can be updated later
|
| 8 |
+
|
| 9 |
+
class BrandKitResponse(BaseModel):
|
| 10 |
+
id: str
|
| 11 |
+
name: str
|
| 12 |
+
description: str | None
|
| 13 |
+
status: str
|
| 14 |
+
is_default: bool
|
| 15 |
+
active_version_id: str | None
|
| 16 |
+
created_at: datetime
|
| 17 |
+
updated_at: datetime
|
| 18 |
+
|
| 19 |
+
class Config:
|
| 20 |
+
from_attributes = True
|
app/brand/service.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.brand.capabilities import brand_capabilities
|
| 4 |
+
from app.brand.errors import BrandAssetInvalidError, BrandGovernanceError
|
| 5 |
+
from app.brand.models import (
|
| 6 |
+
BrandGovernanceSetting,
|
| 7 |
+
BrandKit,
|
| 8 |
+
BrandKitAsset,
|
| 9 |
+
BrandKitPlatformSetting,
|
| 10 |
+
BrandKitVersion,
|
| 11 |
+
)
|
| 12 |
+
from app.brand.repository import BrandRepository
|
| 13 |
+
from app.brand.schemas import (
|
| 14 |
+
BrandApplyProject,
|
| 15 |
+
BrandAssetAttach,
|
| 16 |
+
BrandAssetView,
|
| 17 |
+
BrandCapabilities,
|
| 18 |
+
BrandGovernance,
|
| 19 |
+
BrandKitCreate,
|
| 20 |
+
BrandKitList,
|
| 21 |
+
BrandKitUpdate,
|
| 22 |
+
BrandKitView,
|
| 23 |
+
BrandVersionContent,
|
| 24 |
+
BrandVersionCreate,
|
| 25 |
+
BrandVersionUpdate,
|
| 26 |
+
BrandVersionView,
|
| 27 |
+
)
|
| 28 |
+
from app.brand.validation import validate_platform_settings
|
| 29 |
+
from app.security.assets import CanonicalAssetNotFoundError, CanonicalAssetService
|
| 30 |
+
from app.security.audit import AuditService
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class BrandService:
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
repository: BrandRepository,
|
| 37 |
+
assets: CanonicalAssetService,
|
| 38 |
+
audit: AuditService,
|
| 39 |
+
) -> None:
|
| 40 |
+
self.repository = repository
|
| 41 |
+
self.assets = assets
|
| 42 |
+
self.audit = audit
|
| 43 |
+
|
| 44 |
+
def capabilities(self) -> BrandCapabilities:
|
| 45 |
+
# Configuration is authoritative; the existing render compiler does not
|
| 46 |
+
# yet accept brand watermark instructions as a first-class contract.
|
| 47 |
+
return brand_capabilities(watermark_rendering=False)
|
| 48 |
+
|
| 49 |
+
async def list(
|
| 50 |
+
self,
|
| 51 |
+
*,
|
| 52 |
+
workspace_id: str,
|
| 53 |
+
user_id: str,
|
| 54 |
+
search: str | None,
|
| 55 |
+
status: str,
|
| 56 |
+
offset: int,
|
| 57 |
+
limit: int,
|
| 58 |
+
) -> BrandKitList:
|
| 59 |
+
kits, total = await self.repository.list(
|
| 60 |
+
workspace_id,
|
| 61 |
+
user_id=user_id,
|
| 62 |
+
search=search,
|
| 63 |
+
status=status,
|
| 64 |
+
offset=offset,
|
| 65 |
+
limit=limit,
|
| 66 |
+
)
|
| 67 |
+
return BrandKitList(
|
| 68 |
+
items=[await self._kit_view(kit, user_id=user_id) for kit in kits],
|
| 69 |
+
offset=offset,
|
| 70 |
+
limit=limit,
|
| 71 |
+
total=total,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
async def get(
|
| 75 |
+
self, *, workspace_id: str, user_id: str, brand_kit_id: str
|
| 76 |
+
) -> BrandKitView:
|
| 77 |
+
return await self._kit_view(
|
| 78 |
+
await self.repository.get(workspace_id, brand_kit_id, user_id=user_id),
|
| 79 |
+
user_id=user_id,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
async def create(
|
| 83 |
+
self,
|
| 84 |
+
*,
|
| 85 |
+
workspace_id: str,
|
| 86 |
+
user_id: str,
|
| 87 |
+
api_key_id: str,
|
| 88 |
+
request_id: str,
|
| 89 |
+
payload: BrandKitCreate,
|
| 90 |
+
) -> BrandKitView:
|
| 91 |
+
self._validate_content(payload.initial_version)
|
| 92 |
+
kit, version = await self.repository.create(
|
| 93 |
+
BrandKit(
|
| 94 |
+
workspace_id=workspace_id,
|
| 95 |
+
name=payload.name,
|
| 96 |
+
description=payload.description,
|
| 97 |
+
is_default=payload.is_default,
|
| 98 |
+
created_by=user_id,
|
| 99 |
+
),
|
| 100 |
+
self._version_model(
|
| 101 |
+
workspace_id, "", user_id, payload.initial_version, version=1
|
| 102 |
+
),
|
| 103 |
+
user_id=user_id,
|
| 104 |
+
)
|
| 105 |
+
await self._replace_platform_settings(version, payload.initial_version, user_id)
|
| 106 |
+
await self._audit(
|
| 107 |
+
workspace_id,
|
| 108 |
+
user_id,
|
| 109 |
+
api_key_id,
|
| 110 |
+
request_id,
|
| 111 |
+
"brand.created",
|
| 112 |
+
kit.id,
|
| 113 |
+
{"is_default": kit.is_default},
|
| 114 |
+
)
|
| 115 |
+
await self._audit(
|
| 116 |
+
workspace_id,
|
| 117 |
+
user_id,
|
| 118 |
+
api_key_id,
|
| 119 |
+
request_id,
|
| 120 |
+
"brand.version_created",
|
| 121 |
+
kit.id,
|
| 122 |
+
{"version_id": version.id, "version": version.version},
|
| 123 |
+
)
|
| 124 |
+
return await self.get(
|
| 125 |
+
workspace_id=workspace_id, user_id=user_id, brand_kit_id=kit.id
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
async def update(
|
| 129 |
+
self,
|
| 130 |
+
*,
|
| 131 |
+
workspace_id: str,
|
| 132 |
+
user_id: str,
|
| 133 |
+
api_key_id: str,
|
| 134 |
+
request_id: str,
|
| 135 |
+
brand_kit_id: str,
|
| 136 |
+
payload: BrandKitUpdate,
|
| 137 |
+
) -> BrandKitView:
|
| 138 |
+
kit = await self.repository.update(
|
| 139 |
+
workspace_id,
|
| 140 |
+
brand_kit_id,
|
| 141 |
+
user_id=user_id,
|
| 142 |
+
fields=payload.model_dump(exclude_unset=True),
|
| 143 |
+
)
|
| 144 |
+
await self._audit(
|
| 145 |
+
workspace_id,
|
| 146 |
+
user_id,
|
| 147 |
+
api_key_id,
|
| 148 |
+
request_id,
|
| 149 |
+
"brand.updated",
|
| 150 |
+
kit.id,
|
| 151 |
+
{"fields": sorted(payload.model_fields_set)},
|
| 152 |
+
)
|
| 153 |
+
return await self._kit_view(kit, user_id=user_id)
|
| 154 |
+
|
| 155 |
+
async def delete(
|
| 156 |
+
self,
|
| 157 |
+
*,
|
| 158 |
+
workspace_id: str,
|
| 159 |
+
user_id: str,
|
| 160 |
+
api_key_id: str,
|
| 161 |
+
request_id: str,
|
| 162 |
+
brand_kit_id: str,
|
| 163 |
+
) -> None:
|
| 164 |
+
kit = await self.repository.archive(workspace_id, brand_kit_id, user_id=user_id)
|
| 165 |
+
await self._audit(
|
| 166 |
+
workspace_id,
|
| 167 |
+
user_id,
|
| 168 |
+
api_key_id,
|
| 169 |
+
request_id,
|
| 170 |
+
"brand.deleted",
|
| 171 |
+
kit.id,
|
| 172 |
+
{"disposition": "archived"},
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
async def versions(
|
| 176 |
+
self, *, workspace_id: str, user_id: str, brand_kit_id: str
|
| 177 |
+
) -> list[BrandVersionView]:
|
| 178 |
+
return [
|
| 179 |
+
await self._version_view(item, user_id=user_id)
|
| 180 |
+
for item in await self.repository.versions(
|
| 181 |
+
workspace_id, brand_kit_id, user_id=user_id
|
| 182 |
+
)
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
async def version(
|
| 186 |
+
self,
|
| 187 |
+
*,
|
| 188 |
+
workspace_id: str,
|
| 189 |
+
user_id: str,
|
| 190 |
+
brand_kit_id: str,
|
| 191 |
+
version_id: str,
|
| 192 |
+
) -> BrandVersionView:
|
| 193 |
+
return await self._version_view(
|
| 194 |
+
await self.repository.version(
|
| 195 |
+
workspace_id, brand_kit_id, version_id, user_id=user_id
|
| 196 |
+
),
|
| 197 |
+
user_id=user_id,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
async def create_version(
|
| 201 |
+
self,
|
| 202 |
+
*,
|
| 203 |
+
workspace_id: str,
|
| 204 |
+
user_id: str,
|
| 205 |
+
api_key_id: str,
|
| 206 |
+
request_id: str,
|
| 207 |
+
brand_kit_id: str,
|
| 208 |
+
payload: BrandVersionCreate,
|
| 209 |
+
) -> BrandVersionView:
|
| 210 |
+
content: BrandVersionContent = payload
|
| 211 |
+
if payload.source_version_id:
|
| 212 |
+
source = await self.version(
|
| 213 |
+
workspace_id=workspace_id,
|
| 214 |
+
user_id=user_id,
|
| 215 |
+
brand_kit_id=brand_kit_id,
|
| 216 |
+
version_id=payload.source_version_id,
|
| 217 |
+
)
|
| 218 |
+
if not any(
|
| 219 |
+
field in payload.model_fields_set
|
| 220 |
+
for field in BrandVersionContent.model_fields
|
| 221 |
+
):
|
| 222 |
+
content = BrandVersionContent.model_validate(
|
| 223 |
+
source.model_dump(
|
| 224 |
+
include=set(BrandVersionContent.model_fields), mode="json"
|
| 225 |
+
)
|
| 226 |
+
)
|
| 227 |
+
self._validate_content(content)
|
| 228 |
+
version = await self.repository.create_version(
|
| 229 |
+
self._version_model(workspace_id, brand_kit_id, user_id, content, version=0),
|
| 230 |
+
user_id=user_id,
|
| 231 |
+
)
|
| 232 |
+
await self._replace_platform_settings(version, content, user_id)
|
| 233 |
+
await self._audit(
|
| 234 |
+
workspace_id,
|
| 235 |
+
user_id,
|
| 236 |
+
api_key_id,
|
| 237 |
+
request_id,
|
| 238 |
+
"brand.version_created",
|
| 239 |
+
brand_kit_id,
|
| 240 |
+
{"version_id": version.id, "version": version.version},
|
| 241 |
+
)
|
| 242 |
+
return await self._version_view(version, user_id=user_id)
|
| 243 |
+
|
| 244 |
+
async def update_version(
|
| 245 |
+
self,
|
| 246 |
+
*,
|
| 247 |
+
workspace_id: str,
|
| 248 |
+
user_id: str,
|
| 249 |
+
api_key_id: str,
|
| 250 |
+
request_id: str,
|
| 251 |
+
brand_kit_id: str,
|
| 252 |
+
version_id: str,
|
| 253 |
+
payload: BrandVersionUpdate,
|
| 254 |
+
) -> BrandVersionView:
|
| 255 |
+
self._validate_content(payload)
|
| 256 |
+
version = await self.repository.update_version(
|
| 257 |
+
workspace_id,
|
| 258 |
+
brand_kit_id,
|
| 259 |
+
version_id,
|
| 260 |
+
user_id=user_id,
|
| 261 |
+
fields=self._version_fields(payload),
|
| 262 |
+
)
|
| 263 |
+
await self._replace_platform_settings(version, payload, user_id)
|
| 264 |
+
await self._audit(
|
| 265 |
+
workspace_id,
|
| 266 |
+
user_id,
|
| 267 |
+
api_key_id,
|
| 268 |
+
request_id,
|
| 269 |
+
"brand.updated",
|
| 270 |
+
brand_kit_id,
|
| 271 |
+
{"version_id": version.id},
|
| 272 |
+
)
|
| 273 |
+
return await self._version_view(version, user_id=user_id)
|
| 274 |
+
|
| 275 |
+
async def publish_version(
|
| 276 |
+
self,
|
| 277 |
+
*,
|
| 278 |
+
workspace_id: str,
|
| 279 |
+
user_id: str,
|
| 280 |
+
api_key_id: str,
|
| 281 |
+
request_id: str,
|
| 282 |
+
brand_kit_id: str,
|
| 283 |
+
version_id: str,
|
| 284 |
+
) -> BrandKitView:
|
| 285 |
+
kit, version = await self.repository.publish_version(
|
| 286 |
+
workspace_id, brand_kit_id, version_id, user_id=user_id
|
| 287 |
+
)
|
| 288 |
+
await self._audit(
|
| 289 |
+
workspace_id,
|
| 290 |
+
user_id,
|
| 291 |
+
api_key_id,
|
| 292 |
+
request_id,
|
| 293 |
+
"brand.version_published",
|
| 294 |
+
kit.id,
|
| 295 |
+
{"version_id": version.id, "version": version.version},
|
| 296 |
+
)
|
| 297 |
+
return await self._kit_view(kit, user_id=user_id)
|
| 298 |
+
|
| 299 |
+
async def archive_version(
|
| 300 |
+
self,
|
| 301 |
+
*,
|
| 302 |
+
workspace_id: str,
|
| 303 |
+
user_id: str,
|
| 304 |
+
api_key_id: str,
|
| 305 |
+
request_id: str,
|
| 306 |
+
brand_kit_id: str,
|
| 307 |
+
version_id: str,
|
| 308 |
+
) -> BrandVersionView:
|
| 309 |
+
version = await self.repository.archive_version(
|
| 310 |
+
workspace_id, brand_kit_id, version_id, user_id=user_id
|
| 311 |
+
)
|
| 312 |
+
await self._audit(
|
| 313 |
+
workspace_id,
|
| 314 |
+
user_id,
|
| 315 |
+
api_key_id,
|
| 316 |
+
request_id,
|
| 317 |
+
"brand.version_archived",
|
| 318 |
+
brand_kit_id,
|
| 319 |
+
{"version_id": version.id, "version": version.version},
|
| 320 |
+
)
|
| 321 |
+
return await self._version_view(version, user_id=user_id)
|
| 322 |
+
|
| 323 |
+
async def set_default(
|
| 324 |
+
self,
|
| 325 |
+
*,
|
| 326 |
+
workspace_id: str,
|
| 327 |
+
user_id: str,
|
| 328 |
+
api_key_id: str,
|
| 329 |
+
request_id: str,
|
| 330 |
+
brand_kit_id: str,
|
| 331 |
+
) -> BrandKitView:
|
| 332 |
+
kit = await self.repository.set_default(
|
| 333 |
+
workspace_id, brand_kit_id, user_id=user_id
|
| 334 |
+
)
|
| 335 |
+
await self._audit(
|
| 336 |
+
workspace_id,
|
| 337 |
+
user_id,
|
| 338 |
+
api_key_id,
|
| 339 |
+
request_id,
|
| 340 |
+
"brand.default_changed",
|
| 341 |
+
kit.id,
|
| 342 |
+
{},
|
| 343 |
+
)
|
| 344 |
+
return await self._kit_view(kit, user_id=user_id)
|
| 345 |
+
|
| 346 |
+
async def attach_asset(
|
| 347 |
+
self,
|
| 348 |
+
*,
|
| 349 |
+
workspace_id: str,
|
| 350 |
+
user_id: str,
|
| 351 |
+
brand_kit_id: str,
|
| 352 |
+
payload: BrandAssetAttach,
|
| 353 |
+
) -> BrandAssetView:
|
| 354 |
+
version = await self.repository.version(
|
| 355 |
+
workspace_id, brand_kit_id, payload.version_id, user_id=user_id
|
| 356 |
+
)
|
| 357 |
+
if version.status != "draft":
|
| 358 |
+
raise BrandAssetInvalidError(
|
| 359 |
+
"Assets may only be changed on a draft brand version."
|
| 360 |
+
)
|
| 361 |
+
try:
|
| 362 |
+
asset = await self.assets.get_owned_by_id(
|
| 363 |
+
workspace_id=workspace_id, user_id=user_id, asset_id=payload.asset_id
|
| 364 |
+
)
|
| 365 |
+
except CanonicalAssetNotFoundError as exc:
|
| 366 |
+
raise BrandAssetInvalidError(
|
| 367 |
+
"Brand asset is not an owned canonical asset."
|
| 368 |
+
) from exc
|
| 369 |
+
if not asset.mime_type.startswith("image/"):
|
| 370 |
+
raise BrandAssetInvalidError("Brand logo and watermark assets must be images.")
|
| 371 |
+
association = await self.repository.attach_asset(
|
| 372 |
+
BrandKitAsset(
|
| 373 |
+
workspace_id=workspace_id,
|
| 374 |
+
brand_kit_version_id=version.id,
|
| 375 |
+
asset_id=asset.id,
|
| 376 |
+
role=payload.role,
|
| 377 |
+
),
|
| 378 |
+
user_id=user_id,
|
| 379 |
+
)
|
| 380 |
+
return BrandAssetView(
|
| 381 |
+
id=association.id,
|
| 382 |
+
version_id=version.id,
|
| 383 |
+
asset_id=asset.id,
|
| 384 |
+
role=payload.role,
|
| 385 |
+
mime_type=asset.mime_type,
|
| 386 |
+
filename=asset.filename,
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
async def detach_asset(
|
| 390 |
+
self,
|
| 391 |
+
*,
|
| 392 |
+
workspace_id: str,
|
| 393 |
+
user_id: str,
|
| 394 |
+
brand_kit_id: str,
|
| 395 |
+
version_id: str,
|
| 396 |
+
asset_id: str,
|
| 397 |
+
) -> None:
|
| 398 |
+
await self.repository.version(
|
| 399 |
+
workspace_id, brand_kit_id, version_id, user_id=user_id
|
| 400 |
+
)
|
| 401 |
+
await self.repository.detach_asset(
|
| 402 |
+
workspace_id, version_id, asset_id, user_id=user_id
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
async def governance(
|
| 406 |
+
self, *, workspace_id: str, user_id: str
|
| 407 |
+
) -> BrandGovernance:
|
| 408 |
+
setting = await self.repository.governance(workspace_id, user_id=user_id)
|
| 409 |
+
return BrandGovernance.model_validate(setting, from_attributes=True) if setting else BrandGovernance()
|
| 410 |
+
|
| 411 |
+
async def set_governance(
|
| 412 |
+
self,
|
| 413 |
+
*,
|
| 414 |
+
workspace_id: str,
|
| 415 |
+
user_id: str,
|
| 416 |
+
api_key_id: str,
|
| 417 |
+
request_id: str,
|
| 418 |
+
payload: BrandGovernance,
|
| 419 |
+
) -> BrandGovernance:
|
| 420 |
+
setting = await self.repository.set_governance(
|
| 421 |
+
BrandGovernanceSetting(
|
| 422 |
+
workspace_id=workspace_id,
|
| 423 |
+
updated_by=user_id,
|
| 424 |
+
**payload.model_dump(),
|
| 425 |
+
),
|
| 426 |
+
user_id=user_id,
|
| 427 |
+
)
|
| 428 |
+
await self._audit(
|
| 429 |
+
workspace_id,
|
| 430 |
+
user_id,
|
| 431 |
+
api_key_id,
|
| 432 |
+
request_id,
|
| 433 |
+
"brand.updated",
|
| 434 |
+
setting.id,
|
| 435 |
+
{"governance": True},
|
| 436 |
+
)
|
| 437 |
+
return BrandGovernance.model_validate(setting, from_attributes=True)
|
| 438 |
+
|
| 439 |
+
async def apply_to_project(
|
| 440 |
+
self,
|
| 441 |
+
*,
|
| 442 |
+
workspace_id: str,
|
| 443 |
+
user_id: str,
|
| 444 |
+
api_key_id: str,
|
| 445 |
+
request_id: str,
|
| 446 |
+
brand_kit_id: str,
|
| 447 |
+
payload: BrandApplyProject,
|
| 448 |
+
) -> BrandKitView:
|
| 449 |
+
await self.repository.apply_to_project(
|
| 450 |
+
workspace_id,
|
| 451 |
+
brand_kit_id,
|
| 452 |
+
payload.project_id,
|
| 453 |
+
user_id=user_id,
|
| 454 |
+
)
|
| 455 |
+
await self._audit(
|
| 456 |
+
workspace_id,
|
| 457 |
+
user_id,
|
| 458 |
+
api_key_id,
|
| 459 |
+
request_id,
|
| 460 |
+
"brand.applied_to_project",
|
| 461 |
+
brand_kit_id,
|
| 462 |
+
{"project_id": payload.project_id},
|
| 463 |
+
)
|
| 464 |
+
return await self.get(
|
| 465 |
+
workspace_id=workspace_id, user_id=user_id, brand_kit_id=brand_kit_id
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
async def validate_publish(
|
| 469 |
+
self,
|
| 470 |
+
*,
|
| 471 |
+
workspace_id: str,
|
| 472 |
+
user_id: str,
|
| 473 |
+
project_id: str | None,
|
| 474 |
+
brand_kit_id: str | None,
|
| 475 |
+
brand_kit_version_id: str | None,
|
| 476 |
+
) -> tuple[str | None, str | None]:
|
| 477 |
+
governance = await self.governance(workspace_id=workspace_id, user_id=user_id)
|
| 478 |
+
if governance.require_brand_kit_for_publish and not brand_kit_id:
|
| 479 |
+
raise BrandGovernanceError("A brand kit is required before publishing.")
|
| 480 |
+
if not brand_kit_id:
|
| 481 |
+
return None, None
|
| 482 |
+
kit = await self.repository.get(workspace_id, brand_kit_id, user_id=user_id)
|
| 483 |
+
selected_version = brand_kit_version_id or kit.active_version_id
|
| 484 |
+
if governance.require_published_brand_version and not selected_version:
|
| 485 |
+
raise BrandGovernanceError("A published brand version is required before publishing.")
|
| 486 |
+
if selected_version:
|
| 487 |
+
version = await self.repository.version(
|
| 488 |
+
workspace_id, kit.id, selected_version, user_id=user_id
|
| 489 |
+
)
|
| 490 |
+
if governance.require_published_brand_version and version.status != "published":
|
| 491 |
+
raise BrandGovernanceError("Publishing requires a published brand version.")
|
| 492 |
+
return kit.id, selected_version
|
| 493 |
+
|
| 494 |
+
@staticmethod
|
| 495 |
+
def _validate_content(content: BrandVersionContent) -> None:
|
| 496 |
+
for item in content.platform_settings:
|
| 497 |
+
validate_platform_settings(item.provider, item.settings)
|
| 498 |
+
|
| 499 |
+
@staticmethod
|
| 500 |
+
def _version_fields(content: BrandVersionContent) -> dict[str, object]:
|
| 501 |
+
return {
|
| 502 |
+
"colors_json": [item.model_dump(mode="json") for item in content.colors],
|
| 503 |
+
"typography_json": content.typography.model_dump(mode="json"),
|
| 504 |
+
"voice_json": content.voice.model_dump(mode="json"),
|
| 505 |
+
"ctas_json": content.ctas.model_dump(mode="json"),
|
| 506 |
+
"hashtags_json": content.hashtags.model_dump(mode="json"),
|
| 507 |
+
"watermark_json": content.watermark.model_dump(mode="json"),
|
| 508 |
+
"ai_guidance": content.ai_guidance,
|
| 509 |
+
"metadata_json": content.metadata,
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
def _version_model(
|
| 513 |
+
self,
|
| 514 |
+
workspace_id: str,
|
| 515 |
+
brand_kit_id: str,
|
| 516 |
+
user_id: str,
|
| 517 |
+
content: BrandVersionContent,
|
| 518 |
+
*,
|
| 519 |
+
version: int,
|
| 520 |
+
) -> BrandKitVersion:
|
| 521 |
+
return BrandKitVersion(
|
| 522 |
+
workspace_id=workspace_id,
|
| 523 |
+
brand_kit_id=brand_kit_id,
|
| 524 |
+
version=version,
|
| 525 |
+
status="draft",
|
| 526 |
+
created_by=user_id,
|
| 527 |
+
**self._version_fields(content),
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
async def _replace_platform_settings(
|
| 531 |
+
self, version: BrandKitVersion, content: BrandVersionContent, user_id: str
|
| 532 |
+
) -> None:
|
| 533 |
+
await self.repository.replace_platform_settings(
|
| 534 |
+
version.workspace_id,
|
| 535 |
+
version.id,
|
| 536 |
+
[
|
| 537 |
+
BrandKitPlatformSetting(
|
| 538 |
+
workspace_id=version.workspace_id,
|
| 539 |
+
brand_kit_version_id=version.id,
|
| 540 |
+
provider=item.provider,
|
| 541 |
+
settings_json=item.settings,
|
| 542 |
+
)
|
| 543 |
+
for item in content.platform_settings
|
| 544 |
+
],
|
| 545 |
+
user_id=user_id,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
async def _kit_view(self, kit: BrandKit, *, user_id: str) -> BrandKitView:
|
| 549 |
+
active = (
|
| 550 |
+
await self.repository.version(
|
| 551 |
+
kit.workspace_id,
|
| 552 |
+
kit.id,
|
| 553 |
+
kit.active_version_id,
|
| 554 |
+
user_id=user_id,
|
| 555 |
+
)
|
| 556 |
+
if kit.active_version_id
|
| 557 |
+
else None
|
| 558 |
+
)
|
| 559 |
+
return BrandKitView(
|
| 560 |
+
id=kit.id,
|
| 561 |
+
workspace_id=kit.workspace_id,
|
| 562 |
+
name=kit.name,
|
| 563 |
+
description=kit.description,
|
| 564 |
+
status=kit.status,
|
| 565 |
+
is_default=kit.is_default,
|
| 566 |
+
active_version_id=kit.active_version_id,
|
| 567 |
+
active_version=(
|
| 568 |
+
await self._version_view(active, user_id=user_id) if active else None
|
| 569 |
+
),
|
| 570 |
+
created_by=kit.created_by,
|
| 571 |
+
created_at=kit.created_at,
|
| 572 |
+
updated_at=kit.updated_at,
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
async def _version_view(
|
| 576 |
+
self, version: BrandKitVersion, *, user_id: str
|
| 577 |
+
) -> BrandVersionView:
|
| 578 |
+
settings = await self.repository.platform_settings(
|
| 579 |
+
version.workspace_id, version.id, user_id=user_id
|
| 580 |
+
)
|
| 581 |
+
assets = await self.repository.assets(
|
| 582 |
+
version.workspace_id, version.id, user_id=user_id
|
| 583 |
+
)
|
| 584 |
+
return BrandVersionView(
|
| 585 |
+
id=version.id,
|
| 586 |
+
brand_kit_id=version.brand_kit_id,
|
| 587 |
+
version=version.version,
|
| 588 |
+
status=version.status,
|
| 589 |
+
colors=version.colors_json,
|
| 590 |
+
typography=version.typography_json,
|
| 591 |
+
voice=version.voice_json,
|
| 592 |
+
ctas=version.ctas_json,
|
| 593 |
+
hashtags=version.hashtags_json,
|
| 594 |
+
watermark=version.watermark_json,
|
| 595 |
+
ai_guidance=version.ai_guidance,
|
| 596 |
+
platform_settings=[
|
| 597 |
+
{"provider": item.provider, "settings": item.settings_json}
|
| 598 |
+
for item in settings
|
| 599 |
+
],
|
| 600 |
+
metadata=version.metadata_json,
|
| 601 |
+
assets=[
|
| 602 |
+
BrandAssetView(
|
| 603 |
+
id=association.id,
|
| 604 |
+
version_id=version.id,
|
| 605 |
+
asset_id=asset.id,
|
| 606 |
+
role=association.role,
|
| 607 |
+
mime_type=asset.mime_type,
|
| 608 |
+
filename=asset.filename,
|
| 609 |
+
)
|
| 610 |
+
for association, asset in assets
|
| 611 |
+
],
|
| 612 |
+
created_by=version.created_by,
|
| 613 |
+
created_at=version.created_at,
|
| 614 |
+
published_at=version.published_at,
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
async def _audit(
|
| 618 |
+
self,
|
| 619 |
+
workspace_id: str,
|
| 620 |
+
user_id: str,
|
| 621 |
+
api_key_id: str,
|
| 622 |
+
request_id: str,
|
| 623 |
+
event_type: str,
|
| 624 |
+
entity_id: str,
|
| 625 |
+
metadata: dict[str, object],
|
| 626 |
+
) -> None:
|
| 627 |
+
await self.audit.record_event(
|
| 628 |
+
workspace_id=workspace_id,
|
| 629 |
+
user_id=user_id,
|
| 630 |
+
api_key_id=api_key_id,
|
| 631 |
+
request_id=request_id,
|
| 632 |
+
event_type=event_type,
|
| 633 |
+
entity_type="brand_kit",
|
| 634 |
+
entity_id=entity_id,
|
| 635 |
+
metadata=metadata,
|
| 636 |
+
)
|
app/brand/services/brand_service.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from app.brand.models.brand import BrandKit, BrandKitVersion
|
| 3 |
+
from app.brand.repositories.brand_repository import BrandKitRepository
|
| 4 |
+
from app.security.assets import CanonicalAssetService
|
| 5 |
+
from app.security.audit import AuditService
|
| 6 |
+
|
| 7 |
+
class BrandKitService:
|
| 8 |
+
def __init__(self, repository: BrandKitRepository, assets: CanonicalAssetService, audit: AuditService) -> None:
|
| 9 |
+
self.repository = repository
|
| 10 |
+
self.assets = assets
|
| 11 |
+
self.audit = audit
|
| 12 |
+
|
| 13 |
+
async def _validate_assets(self, workspace_id: str, data: dict[str, object]) -> None:
|
| 14 |
+
for asset_key in ["logo_asset_id", "favicon_asset_id", "watermark_asset_id"]:
|
| 15 |
+
asset_id = data.get(asset_key)
|
| 16 |
+
if asset_id:
|
| 17 |
+
asset = await self.assets.get_asset(workspace_id=workspace_id, asset_id=str(asset_id))
|
| 18 |
+
if not asset:
|
| 19 |
+
raise ValueError(f"Asset {asset_id} not found or inaccessible in this workspace.")
|
| 20 |
+
|
| 21 |
+
async def create_brand_kit(self, workspace_id: str, name: str, data: dict[str, object], *, user_id: str) -> tuple[BrandKit, BrandKitVersion]:
|
| 22 |
+
await self._validate_assets(workspace_id, data)
|
| 23 |
+
kit, version = await self.repository.create(workspace_id, name, data, user_id=user_id)
|
| 24 |
+
await self.audit.log_event(
|
| 25 |
+
workspace_id=workspace_id,
|
| 26 |
+
actor_user_id=user_id,
|
| 27 |
+
event_type="brand_kit.created",
|
| 28 |
+
entity_type="brand_kit",
|
| 29 |
+
entity_id=kit.id,
|
| 30 |
+
metadata={"name": kit.name}
|
| 31 |
+
)
|
| 32 |
+
return kit, version
|
| 33 |
+
|
| 34 |
+
async def get_brand_kit(self, workspace_id: str, brand_kit_id: str, *, user_id: str) -> BrandKit:
|
| 35 |
+
return await self.repository.get(workspace_id, brand_kit_id, user_id=user_id)
|
| 36 |
+
|
| 37 |
+
async def list_brand_kits(self, workspace_id: str, *, user_id: str) -> list[BrandKit]:
|
| 38 |
+
return await self.repository.list(workspace_id, user_id=user_id)
|
| 39 |
+
|
| 40 |
+
async def get_brand_kit_version(self, workspace_id: str, brand_kit_id: str, *, user_id: str) -> BrandKitVersion:
|
| 41 |
+
return await self.repository.get_latest_version(workspace_id, brand_kit_id, user_id=user_id)
|
| 42 |
+
|
| 43 |
+
async def update_brand_kit(self, workspace_id: str, brand_kit_id: str, *, user_id: str, data: dict[str, object]) -> BrandKitVersion:
|
| 44 |
+
await self._validate_assets(workspace_id, data)
|
| 45 |
+
version = await self.repository.create_version(workspace_id, brand_kit_id, data, user_id=user_id)
|
| 46 |
+
await self.audit.log_event(
|
| 47 |
+
workspace_id=workspace_id,
|
| 48 |
+
actor_user_id=user_id,
|
| 49 |
+
event_type="brand_kit.version_created",
|
| 50 |
+
entity_type="brand_kit_version",
|
| 51 |
+
entity_id=version.id,
|
| 52 |
+
metadata={"brand_kit_id": brand_kit_id, "version_number": version.version_number}
|
| 53 |
+
)
|
| 54 |
+
return version
|
| 55 |
+
|
| 56 |
+
async def delete_brand_kit(self, workspace_id: str, brand_kit_id: str, *, user_id: str) -> None:
|
| 57 |
+
await self.repository.delete(workspace_id, brand_kit_id, user_id=user_id)
|
| 58 |
+
await self.audit.log_event(
|
| 59 |
+
workspace_id=workspace_id,
|
| 60 |
+
actor_user_id=user_id,
|
| 61 |
+
event_type="brand_kit.archived",
|
| 62 |
+
entity_type="brand_kit",
|
| 63 |
+
entity_id=brand_kit_id
|
| 64 |
+
)
|
app/brand/services/validation_service.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Any
|
| 3 |
+
from app.brand.models.brand import BrandKitVersion
|
| 4 |
+
|
| 5 |
+
class BrandKitValidationService:
|
| 6 |
+
def validate(self, version: BrandKitVersion) -> dict[str, Any]:
|
| 7 |
+
"""
|
| 8 |
+
Deterministic validation service.
|
| 9 |
+
Returns a dict with 'valid' boolean and 'issues' list of dicts:
|
| 10 |
+
{'severity': 'error'|'warning', 'field': str, 'reason': str}
|
| 11 |
+
"""
|
| 12 |
+
issues = []
|
| 13 |
+
|
| 14 |
+
# Required assets check
|
| 15 |
+
if not version.logo_asset_id:
|
| 16 |
+
issues.append({'severity': 'error', 'field': 'logo_asset_id', 'reason': 'Primary logo is required.'})
|
| 17 |
+
|
| 18 |
+
# Color completeness
|
| 19 |
+
if not version.primary_color:
|
| 20 |
+
issues.append({'severity': 'warning', 'field': 'primary_color', 'reason': 'Primary color is not set.'})
|
| 21 |
+
|
| 22 |
+
# Font checks
|
| 23 |
+
if not version.font_family_primary:
|
| 24 |
+
issues.append({'severity': 'warning', 'field': 'font_family_primary', 'reason': 'Primary font is not set.'})
|
| 25 |
+
|
| 26 |
+
return {
|
| 27 |
+
'valid': len([i for i in issues if i['severity'] == 'error']) == 0,
|
| 28 |
+
'issues': issues
|
| 29 |
+
}
|
app/brand/validation.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from app.brand.errors import BrandValidationError
|
| 6 |
+
|
| 7 |
+
_PLATFORM_FIELDS: dict[str, frozenset[str]] = {
|
| 8 |
+
"facebook": frozenset({"caption_style", "hashtags", "cta"}),
|
| 9 |
+
"instagram": frozenset({"caption_style", "hashtags", "cta", "first_comment"}),
|
| 10 |
+
"tiktok": frozenset({"caption_style", "hashtags", "cta"}),
|
| 11 |
+
"x": frozenset({"caption_style", "hashtags", "cta"}),
|
| 12 |
+
"youtube": frozenset({"title_pattern", "description", "tags", "cta"}),
|
| 13 |
+
"linkedin": frozenset({"post_style", "hashtags", "cta"}),
|
| 14 |
+
"telegram": frozenset({"caption_style", "cta"}),
|
| 15 |
+
"whatsapp": frozenset({"caption_style", "cta"}),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def validate_platform_settings(provider: str, settings: dict[str, Any]) -> None:
|
| 20 |
+
unsupported = set(settings) - _PLATFORM_FIELDS.get(provider, frozenset())
|
| 21 |
+
if unsupported:
|
| 22 |
+
raise BrandValidationError(
|
| 23 |
+
f"Unsupported {provider} brand defaults: {', '.join(sorted(unsupported))}."
|
| 24 |
+
)
|
| 25 |
+
for key, value in settings.items():
|
| 26 |
+
if isinstance(value, str) and len(value) > 4000:
|
| 27 |
+
raise BrandValidationError(f"{provider}.{key} exceeds the supported length.")
|
| 28 |
+
if isinstance(value, list) and len(value) > 100:
|
| 29 |
+
raise BrandValidationError(f"{provider}.{key} contains too many values.")
|
| 30 |
+
|
app/container.py
CHANGED
|
@@ -2,11 +2,48 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from dataclasses import dataclass
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from app.core.config import Settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from app.security.audit import AuditService
|
| 7 |
from app.security.database import SecurityDatabase
|
| 8 |
from app.security.rate_limit import APIKeyRateLimiter
|
| 9 |
from app.security.service import APIKeyService
|
|
|
|
| 10 |
from app.services.cleanup import CleanupService
|
| 11 |
from app.services.downloader import Downloader
|
| 12 |
from app.services.ffmpeg_service import FFmpegService
|
|
@@ -22,6 +59,7 @@ from app.social.oauth.state import OAuthStateService
|
|
| 22 |
from app.social.providers.registry import build_provider_registry
|
| 23 |
from app.social.repositories.accounts import AccountRepository
|
| 24 |
from app.social.repositories.assets import SocialMediaAssetRepository
|
|
|
|
| 25 |
from app.social.repositories.jobs import JobRepository
|
| 26 |
from app.social.repositories.posts import PostRepository
|
| 27 |
from app.social.repositories.tokens import TokenRepository
|
|
@@ -31,14 +69,20 @@ from app.social.services.audit_service import SocialAuditService
|
|
| 31 |
from app.social.services.job_service import JobService
|
| 32 |
from app.social.services.media_asset_service import SocialMediaAssetService
|
| 33 |
from app.social.services.oauth_service import OAuthService
|
|
|
|
| 34 |
from app.social.services.publishing_service import PublishingService
|
| 35 |
from app.social.services.scheduling_service import SchedulingService
|
| 36 |
from app.social.services.social_service import SocialService
|
| 37 |
from app.social.services.token_service import TokenService
|
| 38 |
from app.templates.executor import OperationExecutor, TemplateExecutor
|
| 39 |
from app.templates.loader import TemplateLoader
|
|
|
|
|
|
|
| 40 |
from app.templates.registry import TemplateRegistry
|
| 41 |
from app.templates.validator import TemplateValidator
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
@dataclass(slots=True)
|
|
@@ -55,26 +99,147 @@ class Container:
|
|
| 55 |
processor: MediaProcessor
|
| 56 |
template_registry: TemplateRegistry
|
| 57 |
template_executor: TemplateExecutor
|
|
|
|
| 58 |
security_database: SecurityDatabase
|
|
|
|
|
|
|
| 59 |
api_keys: APIKeyService
|
| 60 |
rate_limiter: APIKeyRateLimiter
|
| 61 |
audit: AuditService
|
| 62 |
social: SocialService
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
def build_container(settings: Settings) -> Container:
|
| 66 |
-
security_database = SecurityDatabase(
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
rate_limiter = APIKeyRateLimiter(security_database)
|
| 69 |
audit = AuditService(security_database)
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
cleanup = CleanupService(settings)
|
|
|
|
|
|
|
|
|
|
| 73 |
validator = MediaValidator(settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
downloader = Downloader(settings, validator)
|
| 75 |
ytdlp = YTDLPService(settings, validator)
|
| 76 |
ffmpeg = FFmpegService(settings)
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
whisper = WhisperService(settings)
|
| 79 |
social_database = SocialDatabase(settings)
|
| 80 |
providers = build_provider_registry(settings)
|
|
@@ -101,26 +266,66 @@ def build_container(settings: Settings) -> Container:
|
|
| 101 |
social_audit,
|
| 102 |
)
|
| 103 |
social_media_assets = SocialMediaAssetService(
|
| 104 |
-
SocialMediaAssetRepository(social_database), cleanup, ffprobe, validator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
)
|
| 106 |
social = SocialService(
|
| 107 |
settings=settings,
|
| 108 |
database=social_database,
|
| 109 |
accounts=account_service,
|
| 110 |
oauth=oauth_service,
|
| 111 |
-
publishing=
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
),
|
| 115 |
-
scheduling=
|
| 116 |
jobs=JobService(job_repository),
|
| 117 |
media_assets=social_media_assets,
|
| 118 |
-
analytics=
|
| 119 |
audit=social_audit,
|
| 120 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator)
|
| 122 |
processor = MediaProcessor(
|
| 123 |
-
settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper
|
| 124 |
)
|
| 125 |
operation_executor = OperationExecutor(processor)
|
| 126 |
template_validator = TemplateValidator(operation_executor.supported_operations)
|
|
@@ -141,9 +346,27 @@ def build_container(settings: Settings) -> Container:
|
|
| 141 |
processor=processor,
|
| 142 |
template_registry=template_registry,
|
| 143 |
template_executor=template_executor,
|
|
|
|
| 144 |
security_database=security_database,
|
|
|
|
|
|
|
| 145 |
api_keys=api_keys,
|
| 146 |
rate_limiter=rate_limiter,
|
| 147 |
audit=audit,
|
| 148 |
social=social,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
)
|
|
|
|
| 2 |
|
| 3 |
from dataclasses import dataclass
|
| 4 |
|
| 5 |
+
from app.ai.service import AiStudioService
|
| 6 |
+
from app.brand.repositories.brand_repository import BrandKitRepository
|
| 7 |
+
from app.brand.services.brand_service import BrandKitService
|
| 8 |
+
from app.analytics.service import AnalyticsDomainService
|
| 9 |
+
from app.analytics.workers.sync import AnalyticsSyncWorker
|
| 10 |
+
from app.copilot.actions import CopilotActionRegistry
|
| 11 |
+
from app.copilot.planner import CopilotPlanner
|
| 12 |
+
from app.copilot.repository import CopilotRepository
|
| 13 |
+
from app.copilot.service import CopilotService
|
| 14 |
from app.core.config import Settings
|
| 15 |
+
from app.generation.model_registry import GenerationModelRegistration, GenerationModelRegistry
|
| 16 |
+
from app.generation.providers.flux import (
|
| 17 |
+
FLUX_BASE_MODEL_ID,
|
| 18 |
+
FLUX_DISTILLED_MODEL_ID,
|
| 19 |
+
FLUX_MODEL_CAPABILITY,
|
| 20 |
+
FLUX_PROVIDER_ID,
|
| 21 |
+
FLUX_TASK,
|
| 22 |
+
FluxProviderAdapter,
|
| 23 |
+
)
|
| 24 |
+
from app.generation.providers.registry import GenerationProviderRegistry
|
| 25 |
+
from app.generation.providers.wan import WAN_MODEL_CAPABILITY, WAN_PROVIDER_ID, WanProviderAdapter
|
| 26 |
+
from app.generation.repositories.generation import GenerationRepository
|
| 27 |
+
from app.generation.services.generation_service import GenerationService
|
| 28 |
+
from app.generation.services.output_ingestion import GenerationOutputIngestor
|
| 29 |
+
from app.generation.workers.generation_worker import GenerationWorker
|
| 30 |
+
from app.projects.repositories.editor_repository import ProjectEditorRepository
|
| 31 |
+
from app.projects.repositories.project_repository import ProjectRepository
|
| 32 |
+
from app.projects.repositories.render_repository import ProjectRenderRepository
|
| 33 |
+
from app.projects.repositories.collaboration_repository import CollaborationRepository
|
| 34 |
+
from app.projects.repositories.approval_repository import ApprovalRepository
|
| 35 |
+
from app.projects.services.editor_service import ProjectEditorService
|
| 36 |
+
from app.projects.services.project_service import ProjectService
|
| 37 |
+
from app.projects.services.collaboration_service import CollaborationService
|
| 38 |
+
from app.projects.services.approval_service import ApprovalService
|
| 39 |
+
from app.projects.services.render_service import ProjectRenderService
|
| 40 |
+
from app.projects.workers.render_worker import ProjectRenderWorker
|
| 41 |
+
from app.security.assets import CanonicalAssetService
|
| 42 |
from app.security.audit import AuditService
|
| 43 |
from app.security.database import SecurityDatabase
|
| 44 |
from app.security.rate_limit import APIKeyRateLimiter
|
| 45 |
from app.security.service import APIKeyService
|
| 46 |
+
from app.security.tenancy import TenantService
|
| 47 |
from app.services.cleanup import CleanupService
|
| 48 |
from app.services.downloader import Downloader
|
| 49 |
from app.services.ffmpeg_service import FFmpegService
|
|
|
|
| 59 |
from app.social.providers.registry import build_provider_registry
|
| 60 |
from app.social.repositories.accounts import AccountRepository
|
| 61 |
from app.social.repositories.assets import SocialMediaAssetRepository
|
| 62 |
+
from app.social.repositories.batches import PublishingBatchRepository
|
| 63 |
from app.social.repositories.jobs import JobRepository
|
| 64 |
from app.social.repositories.posts import PostRepository
|
| 65 |
from app.social.repositories.tokens import TokenRepository
|
|
|
|
| 69 |
from app.social.services.job_service import JobService
|
| 70 |
from app.social.services.media_asset_service import SocialMediaAssetService
|
| 71 |
from app.social.services.oauth_service import OAuthService
|
| 72 |
+
from app.social.services.publishing_operations_service import PublishingOperationsService
|
| 73 |
from app.social.services.publishing_service import PublishingService
|
| 74 |
from app.social.services.scheduling_service import SchedulingService
|
| 75 |
from app.social.services.social_service import SocialService
|
| 76 |
from app.social.services.token_service import TokenService
|
| 77 |
from app.templates.executor import OperationExecutor, TemplateExecutor
|
| 78 |
from app.templates.loader import TemplateLoader
|
| 79 |
+
from app.templates.marketplace_repository import MarketplaceTemplateRepository
|
| 80 |
+
from app.templates.marketplace_service import MarketplaceTemplateService
|
| 81 |
from app.templates.registry import TemplateRegistry
|
| 82 |
from app.templates.validator import TemplateValidator
|
| 83 |
+
from app.analytics.repository import AnalyticsRepository
|
| 84 |
+
from app.projects.repositories.notification_repository import NotificationRepository
|
| 85 |
+
from app.projects.services.notification_service import NotificationService
|
| 86 |
|
| 87 |
|
| 88 |
@dataclass(slots=True)
|
|
|
|
| 99 |
processor: MediaProcessor
|
| 100 |
template_registry: TemplateRegistry
|
| 101 |
template_executor: TemplateExecutor
|
| 102 |
+
template_marketplace: MarketplaceTemplateService
|
| 103 |
security_database: SecurityDatabase
|
| 104 |
+
tenants: TenantService
|
| 105 |
+
assets: CanonicalAssetService
|
| 106 |
api_keys: APIKeyService
|
| 107 |
rate_limiter: APIKeyRateLimiter
|
| 108 |
audit: AuditService
|
| 109 |
social: SocialService
|
| 110 |
+
ai: AiStudioService
|
| 111 |
+
copilot: CopilotService
|
| 112 |
+
generation: GenerationService
|
| 113 |
+
generation_models: GenerationModelRegistry
|
| 114 |
+
generation_worker: GenerationWorker
|
| 115 |
+
projects: ProjectService
|
| 116 |
+
editor: ProjectEditorService
|
| 117 |
+
renders: ProjectRenderService
|
| 118 |
+
render_worker: ProjectRenderWorker
|
| 119 |
+
analytics: AnalyticsDomainService
|
| 120 |
+
analytics_worker: AnalyticsSyncWorker
|
| 121 |
+
brand_kits: BrandKitService
|
| 122 |
+
collaboration: CollaborationService
|
| 123 |
+
approval: ApprovalService
|
| 124 |
+
notifications: NotificationService
|
| 125 |
|
| 126 |
def build_container(settings: Settings) -> Container:
|
| 127 |
+
security_database = SecurityDatabase(
|
| 128 |
+
settings.database_url, auto_migrate=settings.security_auto_migrate
|
| 129 |
+
)
|
| 130 |
+
tenants = TenantService(security_database)
|
| 131 |
+
assets = CanonicalAssetService(security_database)
|
| 132 |
+
api_keys = APIKeyService(security_database, settings, tenants)
|
| 133 |
rate_limiter = APIKeyRateLimiter(security_database)
|
| 134 |
audit = AuditService(security_database)
|
| 135 |
+
brand_repository = BrandKitRepository(security_database)
|
| 136 |
+
brand_kits = BrandKitService(brand_repository, assets, audit)
|
| 137 |
+
projects = ProjectService(ProjectRepository(security_database), assets, audit, brand_kits)
|
| 138 |
+
collaboration = CollaborationService(CollaborationRepository(security_database))
|
| 139 |
+
approval = ApprovalService(ApprovalRepository(security_database), ProjectEditorRepository(security_database))
|
| 140 |
+
notifications = NotificationService(NotificationRepository(security_database))
|
| 141 |
cleanup = CleanupService(settings)
|
| 142 |
+
# Generation output validation shares the established FFprobe/validator
|
| 143 |
+
# services. Construct them before the output ingestor, without changing
|
| 144 |
+
# existing media or social provider behavior.
|
| 145 |
validator = MediaValidator(settings)
|
| 146 |
+
ffprobe = FFprobeService(settings)
|
| 147 |
+
wan = WanProviderAdapter.from_settings(settings)
|
| 148 |
+
flux = FluxProviderAdapter.from_settings(settings)
|
| 149 |
+
generation_providers = GenerationProviderRegistry([wan, flux])
|
| 150 |
+
generation_models = GenerationModelRegistry(
|
| 151 |
+
[
|
| 152 |
+
GenerationModelRegistration(
|
| 153 |
+
provider_id=WAN_PROVIDER_ID,
|
| 154 |
+
model=WAN_MODEL_CAPABILITY,
|
| 155 |
+
configuration_reference="wan-space",
|
| 156 |
+
metadata={
|
| 157 |
+
"underlying_model_id": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
|
| 158 |
+
"task": "image-to-video",
|
| 159 |
+
"fps": 16,
|
| 160 |
+
},
|
| 161 |
+
),
|
| 162 |
+
GenerationModelRegistration(
|
| 163 |
+
provider_id=FLUX_PROVIDER_ID,
|
| 164 |
+
model=FLUX_MODEL_CAPABILITY,
|
| 165 |
+
configuration_reference="flux-space",
|
| 166 |
+
metadata={
|
| 167 |
+
"task": FLUX_TASK,
|
| 168 |
+
"license": "Apache-2.0",
|
| 169 |
+
"models": {
|
| 170 |
+
"distilled": FLUX_DISTILLED_MODEL_ID,
|
| 171 |
+
"base": FLUX_BASE_MODEL_ID,
|
| 172 |
+
},
|
| 173 |
+
},
|
| 174 |
+
),
|
| 175 |
+
]
|
| 176 |
+
)
|
| 177 |
+
generation = GenerationService(
|
| 178 |
+
settings=settings,
|
| 179 |
+
database=security_database,
|
| 180 |
+
assets=assets,
|
| 181 |
+
repository=GenerationRepository(security_database),
|
| 182 |
+
providers=generation_providers,
|
| 183 |
+
models=generation_models,
|
| 184 |
+
output_ingestor=GenerationOutputIngestor(
|
| 185 |
+
settings=settings,
|
| 186 |
+
cleanup=cleanup,
|
| 187 |
+
assets=assets,
|
| 188 |
+
ffprobe=ffprobe,
|
| 189 |
+
validator=validator,
|
| 190 |
+
),
|
| 191 |
+
)
|
| 192 |
+
generation_worker = GenerationWorker(
|
| 193 |
+
settings=settings,
|
| 194 |
+
generation=generation,
|
| 195 |
+
assets=assets,
|
| 196 |
+
cleanup=cleanup,
|
| 197 |
+
audit=audit,
|
| 198 |
+
)
|
| 199 |
+
ai = AiStudioService(generation, projects, assets, audit)
|
| 200 |
+
# Social publishing validates the same file-backed outputs as the normal
|
| 201 |
+
# media pipeline, so build those shared services before wiring Social.
|
| 202 |
downloader = Downloader(settings, validator)
|
| 203 |
ytdlp = YTDLPService(settings, validator)
|
| 204 |
ffmpeg = FFmpegService(settings)
|
| 205 |
+
editor_repository = ProjectEditorRepository(security_database)
|
| 206 |
+
editor = ProjectEditorService(
|
| 207 |
+
editor_repository,
|
| 208 |
+
assets,
|
| 209 |
+
audit,
|
| 210 |
+
state_max_bytes=settings.editor_state_max_bytes,
|
| 211 |
+
max_tracks=settings.render_max_tracks,
|
| 212 |
+
max_clips=settings.render_max_clips,
|
| 213 |
+
max_duration_seconds=settings.render_max_duration_seconds,
|
| 214 |
+
)
|
| 215 |
+
render_repository = ProjectRenderRepository(security_database)
|
| 216 |
+
renders = ProjectRenderService(
|
| 217 |
+
settings, render_repository, editor_repository, assets, cleanup, audit
|
| 218 |
+
)
|
| 219 |
+
render_worker = ProjectRenderWorker(
|
| 220 |
+
settings, render_repository, renders, ffmpeg, cleanup, audit
|
| 221 |
+
)
|
| 222 |
+
template_marketplace = MarketplaceTemplateService(
|
| 223 |
+
MarketplaceTemplateRepository(security_database), assets, ai, audit
|
| 224 |
+
)
|
| 225 |
+
copilot_actions = CopilotActionRegistry(
|
| 226 |
+
projects=projects,
|
| 227 |
+
assets=assets,
|
| 228 |
+
editor=editor,
|
| 229 |
+
renders=renders,
|
| 230 |
+
ai=ai,
|
| 231 |
+
templates=template_marketplace,
|
| 232 |
+
)
|
| 233 |
+
copilot = CopilotService(
|
| 234 |
+
repository=CopilotRepository(security_database),
|
| 235 |
+
planner=CopilotPlanner(),
|
| 236 |
+
actions=copilot_actions,
|
| 237 |
+
projects=projects,
|
| 238 |
+
assets=assets,
|
| 239 |
+
editor=editor,
|
| 240 |
+
ai=ai,
|
| 241 |
+
audit=audit,
|
| 242 |
+
)
|
| 243 |
whisper = WhisperService(settings)
|
| 244 |
social_database = SocialDatabase(settings)
|
| 245 |
providers = build_provider_registry(settings)
|
|
|
|
| 266 |
social_audit,
|
| 267 |
)
|
| 268 |
social_media_assets = SocialMediaAssetService(
|
| 269 |
+
settings, SocialMediaAssetRepository(social_database), assets, cleanup, ffprobe, validator
|
| 270 |
+
)
|
| 271 |
+
publishing_service = PublishingService(
|
| 272 |
+
settings,
|
| 273 |
+
post_repository,
|
| 274 |
+
job_repository,
|
| 275 |
+
account_repository,
|
| 276 |
+
providers,
|
| 277 |
+
social_media_assets,
|
| 278 |
+
oauth_service,
|
| 279 |
+
brand_kits,
|
| 280 |
+
projects,
|
| 281 |
+
)
|
| 282 |
+
scheduling_service = SchedulingService(post_repository, social_media_assets)
|
| 283 |
+
social_analytics = AnalyticsService(
|
| 284 |
+
social_database, account_repository, providers, oauth_service
|
| 285 |
)
|
| 286 |
social = SocialService(
|
| 287 |
settings=settings,
|
| 288 |
database=social_database,
|
| 289 |
accounts=account_service,
|
| 290 |
oauth=oauth_service,
|
| 291 |
+
publishing=publishing_service,
|
| 292 |
+
operations=PublishingOperationsService(
|
| 293 |
+
security_database=security_database,
|
| 294 |
+
posts=post_repository,
|
| 295 |
+
jobs=job_repository,
|
| 296 |
+
accounts=account_repository,
|
| 297 |
+
batches=PublishingBatchRepository(social_database),
|
| 298 |
+
publishing=publishing_service,
|
| 299 |
+
scheduling=scheduling_service,
|
| 300 |
+
audit=social_audit,
|
| 301 |
),
|
| 302 |
+
scheduling=scheduling_service,
|
| 303 |
jobs=JobService(job_repository),
|
| 304 |
media_assets=social_media_assets,
|
| 305 |
+
analytics=social_analytics,
|
| 306 |
audit=social_audit,
|
| 307 |
)
|
| 308 |
+
analytics = AnalyticsDomainService(
|
| 309 |
+
AnalyticsRepository(social_database),
|
| 310 |
+
providers,
|
| 311 |
+
social_analytics,
|
| 312 |
+
account_repository,
|
| 313 |
+
social_audit,
|
| 314 |
+
projects,
|
| 315 |
+
)
|
| 316 |
+
brand_repository = BrandKitRepository(security_database)
|
| 317 |
+
brand_kits = BrandKitService(brand_repository, assets, audit)
|
| 318 |
+
analytics_worker = AnalyticsSyncWorker(
|
| 319 |
+
analytics,
|
| 320 |
+
social_database,
|
| 321 |
+
interval_seconds=settings.social_scheduler_interval_seconds,
|
| 322 |
+
)
|
| 323 |
+
copilot_actions.analytics = analytics
|
| 324 |
+
copilot_actions.publishing = social.publishing
|
| 325 |
+
copilot_actions.scheduling = social.scheduling
|
| 326 |
resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator)
|
| 327 |
processor = MediaProcessor(
|
| 328 |
+
settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper, assets
|
| 329 |
)
|
| 330 |
operation_executor = OperationExecutor(processor)
|
| 331 |
template_validator = TemplateValidator(operation_executor.supported_operations)
|
|
|
|
| 346 |
processor=processor,
|
| 347 |
template_registry=template_registry,
|
| 348 |
template_executor=template_executor,
|
| 349 |
+
template_marketplace=template_marketplace,
|
| 350 |
security_database=security_database,
|
| 351 |
+
tenants=tenants,
|
| 352 |
+
assets=assets,
|
| 353 |
api_keys=api_keys,
|
| 354 |
rate_limiter=rate_limiter,
|
| 355 |
audit=audit,
|
| 356 |
social=social,
|
| 357 |
+
ai=ai,
|
| 358 |
+
copilot=copilot,
|
| 359 |
+
generation=generation,
|
| 360 |
+
generation_models=generation.models,
|
| 361 |
+
generation_worker=generation_worker,
|
| 362 |
+
projects=projects,
|
| 363 |
+
editor=editor,
|
| 364 |
+
renders=renders,
|
| 365 |
+
render_worker=render_worker,
|
| 366 |
+
analytics=analytics,
|
| 367 |
+
analytics_worker=analytics_worker,
|
| 368 |
+
brand_kits=brand_kits,
|
| 369 |
+
collaboration=collaboration,
|
| 370 |
+
approval=approval,
|
| 371 |
+
notifications=notifications,
|
| 372 |
)
|
app/copilot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Validated orchestration over MediaRouter's existing product services."""
|
app/copilot/actions.py
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from app.ai.schemas import AiGenerateImageRequest, AiGenerateVideoRequest
|
| 7 |
+
from app.ai.service import AiStudioService
|
| 8 |
+
from app.analytics.schemas import AnalyticsQuery, AnalyticsSyncRequest
|
| 9 |
+
from app.analytics.service import AnalyticsDomainService
|
| 10 |
+
from app.copilot.errors import (
|
| 11 |
+
CopilotCapabilityError,
|
| 12 |
+
CopilotInvalidRequestError,
|
| 13 |
+
CopilotPermissionError,
|
| 14 |
+
)
|
| 15 |
+
from app.copilot.schemas import (
|
| 16 |
+
AiGenerateImageAction,
|
| 17 |
+
AiGenerateVideoAction,
|
| 18 |
+
AnalyticsOverviewAction,
|
| 19 |
+
AnalyticsSyncAction,
|
| 20 |
+
AssetSelectAction,
|
| 21 |
+
CopilotAction,
|
| 22 |
+
CopilotActionCapability,
|
| 23 |
+
CopilotActionResult,
|
| 24 |
+
EditorAddClipAction,
|
| 25 |
+
EditorDeleteClipAction,
|
| 26 |
+
EditorRenderAction,
|
| 27 |
+
EditorSetDurationAction,
|
| 28 |
+
EditorSplitClipAction,
|
| 29 |
+
ProjectOpenAction,
|
| 30 |
+
PublishingCancelAction,
|
| 31 |
+
PublishingCreatePostAction,
|
| 32 |
+
PublishingPublishAction,
|
| 33 |
+
PublishingScheduleAction,
|
| 34 |
+
PublishingValidateAction,
|
| 35 |
+
TemplateApplyAction,
|
| 36 |
+
TemplateCreateProjectAction,
|
| 37 |
+
TemplateGetAction,
|
| 38 |
+
TemplateSearchAction,
|
| 39 |
+
)
|
| 40 |
+
from app.projects.editor_schemas import (
|
| 41 |
+
AudioClip,
|
| 42 |
+
ClipTransform,
|
| 43 |
+
EditorDocument,
|
| 44 |
+
EditorSaveRequest,
|
| 45 |
+
EffectClip,
|
| 46 |
+
MediaClip,
|
| 47 |
+
ProjectRenderCreate,
|
| 48 |
+
SourceClip,
|
| 49 |
+
Track,
|
| 50 |
+
)
|
| 51 |
+
from app.projects.services.editor_service import ProjectEditorService
|
| 52 |
+
from app.projects.services.project_service import ProjectService
|
| 53 |
+
from app.projects.services.render_service import ProjectRenderService
|
| 54 |
+
from app.security.assets import CanonicalAssetService
|
| 55 |
+
from app.social.services.publishing_service import PublishingService
|
| 56 |
+
from app.social.services.scheduling_service import SchedulingService
|
| 57 |
+
from app.templates.marketplace_schemas import TemplateApply, TemplateInstantiate
|
| 58 |
+
from app.templates.marketplace_service import MarketplaceTemplateService
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass(frozen=True, slots=True)
|
| 62 |
+
class CopilotActionDefinition:
|
| 63 |
+
type: str
|
| 64 |
+
description: str
|
| 65 |
+
required_permission: str
|
| 66 |
+
required_capability: str
|
| 67 |
+
destructive: bool
|
| 68 |
+
external_side_effect: bool
|
| 69 |
+
requires_confirmation: bool
|
| 70 |
+
audit_event: str
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
ACTION_DEFINITIONS = (
|
| 74 |
+
CopilotActionDefinition(
|
| 75 |
+
"project.open",
|
| 76 |
+
"Open a project.",
|
| 77 |
+
"projects:read",
|
| 78 |
+
"project.open",
|
| 79 |
+
False,
|
| 80 |
+
False,
|
| 81 |
+
False,
|
| 82 |
+
"copilot.project_opened",
|
| 83 |
+
),
|
| 84 |
+
CopilotActionDefinition(
|
| 85 |
+
"asset.select",
|
| 86 |
+
"Open a canonical asset.",
|
| 87 |
+
"assets:read",
|
| 88 |
+
"asset.select",
|
| 89 |
+
False,
|
| 90 |
+
False,
|
| 91 |
+
False,
|
| 92 |
+
"copilot.asset_selected",
|
| 93 |
+
),
|
| 94 |
+
CopilotActionDefinition(
|
| 95 |
+
"ai.generate_image",
|
| 96 |
+
"Submit image generation.",
|
| 97 |
+
"ai:generate",
|
| 98 |
+
"ai.generate_image",
|
| 99 |
+
False,
|
| 100 |
+
False,
|
| 101 |
+
True,
|
| 102 |
+
"copilot.ai_generation_requested",
|
| 103 |
+
),
|
| 104 |
+
CopilotActionDefinition(
|
| 105 |
+
"ai.generate_video",
|
| 106 |
+
"Submit video generation.",
|
| 107 |
+
"ai:generate",
|
| 108 |
+
"ai.generate_video",
|
| 109 |
+
False,
|
| 110 |
+
False,
|
| 111 |
+
True,
|
| 112 |
+
"copilot.ai_generation_requested",
|
| 113 |
+
),
|
| 114 |
+
CopilotActionDefinition(
|
| 115 |
+
"editor.split_clip",
|
| 116 |
+
"Split a timeline clip.",
|
| 117 |
+
"projects:update",
|
| 118 |
+
"editor.split_clip",
|
| 119 |
+
False,
|
| 120 |
+
False,
|
| 121 |
+
False,
|
| 122 |
+
"copilot.editor_updated",
|
| 123 |
+
),
|
| 124 |
+
CopilotActionDefinition(
|
| 125 |
+
"editor.delete_clip",
|
| 126 |
+
"Delete a timeline clip.",
|
| 127 |
+
"projects:update",
|
| 128 |
+
"editor.delete_clip",
|
| 129 |
+
True,
|
| 130 |
+
False,
|
| 131 |
+
True,
|
| 132 |
+
"copilot.editor_updated",
|
| 133 |
+
),
|
| 134 |
+
CopilotActionDefinition(
|
| 135 |
+
"editor.set_duration",
|
| 136 |
+
"Set a clip duration.",
|
| 137 |
+
"projects:update",
|
| 138 |
+
"editor.set_duration",
|
| 139 |
+
False,
|
| 140 |
+
False,
|
| 141 |
+
False,
|
| 142 |
+
"copilot.editor_updated",
|
| 143 |
+
),
|
| 144 |
+
CopilotActionDefinition(
|
| 145 |
+
"editor.add_clip",
|
| 146 |
+
"Add an asset to the timeline.",
|
| 147 |
+
"projects:update",
|
| 148 |
+
"editor.add_clip",
|
| 149 |
+
False,
|
| 150 |
+
False,
|
| 151 |
+
False,
|
| 152 |
+
"copilot.editor_updated",
|
| 153 |
+
),
|
| 154 |
+
CopilotActionDefinition(
|
| 155 |
+
"editor.render",
|
| 156 |
+
"Submit a project render.",
|
| 157 |
+
"projects:update",
|
| 158 |
+
"editor.render",
|
| 159 |
+
False,
|
| 160 |
+
False,
|
| 161 |
+
True,
|
| 162 |
+
"copilot.render_requested",
|
| 163 |
+
),
|
| 164 |
+
CopilotActionDefinition(
|
| 165 |
+
"template.search",
|
| 166 |
+
"Search visible marketplace templates.",
|
| 167 |
+
"templates:read",
|
| 168 |
+
"template.search",
|
| 169 |
+
False,
|
| 170 |
+
False,
|
| 171 |
+
False,
|
| 172 |
+
"copilot.template_searched",
|
| 173 |
+
),
|
| 174 |
+
CopilotActionDefinition(
|
| 175 |
+
"template.get",
|
| 176 |
+
"Inspect a visible marketplace template.",
|
| 177 |
+
"templates:read",
|
| 178 |
+
"template.get",
|
| 179 |
+
False,
|
| 180 |
+
False,
|
| 181 |
+
False,
|
| 182 |
+
"copilot.template_opened",
|
| 183 |
+
),
|
| 184 |
+
CopilotActionDefinition(
|
| 185 |
+
"template.apply",
|
| 186 |
+
"Apply a template to an existing project.",
|
| 187 |
+
"templates:apply",
|
| 188 |
+
"template.apply",
|
| 189 |
+
True,
|
| 190 |
+
False,
|
| 191 |
+
True,
|
| 192 |
+
"copilot.template_applied",
|
| 193 |
+
),
|
| 194 |
+
CopilotActionDefinition(
|
| 195 |
+
"template.create_project",
|
| 196 |
+
"Create a project from a template.",
|
| 197 |
+
"templates:apply",
|
| 198 |
+
"template.create_project",
|
| 199 |
+
False,
|
| 200 |
+
False,
|
| 201 |
+
True,
|
| 202 |
+
"copilot.template_project_created",
|
| 203 |
+
),
|
| 204 |
+
CopilotActionDefinition(
|
| 205 |
+
"publishing.validate",
|
| 206 |
+
"Validate every social publishing target.",
|
| 207 |
+
"social:posts:write",
|
| 208 |
+
"publishing.validate",
|
| 209 |
+
False,
|
| 210 |
+
False,
|
| 211 |
+
False,
|
| 212 |
+
"copilot.publishing_validated",
|
| 213 |
+
),
|
| 214 |
+
CopilotActionDefinition(
|
| 215 |
+
"publishing.create_post",
|
| 216 |
+
"Create a typed canonical social post draft.",
|
| 217 |
+
"social:posts:write",
|
| 218 |
+
"publishing.create_post",
|
| 219 |
+
False,
|
| 220 |
+
False,
|
| 221 |
+
False,
|
| 222 |
+
"copilot.publishing_draft_created",
|
| 223 |
+
),
|
| 224 |
+
CopilotActionDefinition(
|
| 225 |
+
"publishing.schedule",
|
| 226 |
+
"Schedule an existing social post.",
|
| 227 |
+
"social:schedules:write",
|
| 228 |
+
"publishing.schedule",
|
| 229 |
+
False,
|
| 230 |
+
True,
|
| 231 |
+
True,
|
| 232 |
+
"copilot.publishing_scheduled",
|
| 233 |
+
),
|
| 234 |
+
CopilotActionDefinition(
|
| 235 |
+
"publishing.publish",
|
| 236 |
+
"Publish an existing social post to its selected accounts.",
|
| 237 |
+
"social:posts:publish",
|
| 238 |
+
"publishing.publish",
|
| 239 |
+
False,
|
| 240 |
+
True,
|
| 241 |
+
True,
|
| 242 |
+
"copilot.publishing_started",
|
| 243 |
+
),
|
| 244 |
+
CopilotActionDefinition(
|
| 245 |
+
"publishing.cancel",
|
| 246 |
+
"Cancel eligible publishing targets or request in-flight cancellation.",
|
| 247 |
+
"social:posts:write",
|
| 248 |
+
"publishing.cancel",
|
| 249 |
+
False,
|
| 250 |
+
True,
|
| 251 |
+
True,
|
| 252 |
+
"copilot.publishing_cancelled",
|
| 253 |
+
),
|
| 254 |
+
CopilotActionDefinition(
|
| 255 |
+
"analytics.overview",
|
| 256 |
+
"Read authoritative analytics insights.",
|
| 257 |
+
"analytics:read",
|
| 258 |
+
"analytics.overview",
|
| 259 |
+
False,
|
| 260 |
+
False,
|
| 261 |
+
False,
|
| 262 |
+
"copilot.analytics_viewed",
|
| 263 |
+
),
|
| 264 |
+
CopilotActionDefinition(
|
| 265 |
+
"analytics.sync",
|
| 266 |
+
"Queue authoritative analytics synchronization.",
|
| 267 |
+
"analytics:sync",
|
| 268 |
+
"analytics.sync",
|
| 269 |
+
False,
|
| 270 |
+
False,
|
| 271 |
+
True,
|
| 272 |
+
"copilot.analytics_sync_requested",
|
| 273 |
+
),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class CopilotActionRegistry:
|
| 278 |
+
def __init__(
|
| 279 |
+
self,
|
| 280 |
+
*,
|
| 281 |
+
projects: ProjectService,
|
| 282 |
+
assets: CanonicalAssetService,
|
| 283 |
+
editor: ProjectEditorService,
|
| 284 |
+
renders: ProjectRenderService,
|
| 285 |
+
ai: AiStudioService,
|
| 286 |
+
templates: MarketplaceTemplateService,
|
| 287 |
+
publishing: PublishingService | None = None,
|
| 288 |
+
scheduling: SchedulingService | None = None,
|
| 289 |
+
analytics: AnalyticsDomainService | None = None,
|
| 290 |
+
) -> None:
|
| 291 |
+
self.projects = projects
|
| 292 |
+
self.assets = assets
|
| 293 |
+
self.editor = editor
|
| 294 |
+
self.renders = renders
|
| 295 |
+
self.ai = ai
|
| 296 |
+
self.templates = templates
|
| 297 |
+
self.publishing = publishing
|
| 298 |
+
self.scheduling = scheduling
|
| 299 |
+
self.analytics = analytics
|
| 300 |
+
self.definitions = {item.type: item for item in ACTION_DEFINITIONS}
|
| 301 |
+
|
| 302 |
+
def validate(self, action: CopilotAction) -> CopilotActionDefinition:
|
| 303 |
+
definition = self.definitions.get(action.type)
|
| 304 |
+
if definition is None:
|
| 305 |
+
raise CopilotInvalidRequestError("Copilot action type is not registered.")
|
| 306 |
+
expected = {
|
| 307 |
+
"required_permission": definition.required_permission,
|
| 308 |
+
"required_capability": definition.required_capability,
|
| 309 |
+
"destructive": definition.destructive,
|
| 310 |
+
"external_side_effect": definition.external_side_effect,
|
| 311 |
+
"requires_confirmation": definition.requires_confirmation,
|
| 312 |
+
}
|
| 313 |
+
if any(getattr(action, field) != value for field, value in expected.items()):
|
| 314 |
+
raise CopilotInvalidRequestError(
|
| 315 |
+
"Copilot action policy metadata does not match the registered action."
|
| 316 |
+
)
|
| 317 |
+
return definition
|
| 318 |
+
|
| 319 |
+
def validate_plan(self, actions: list[CopilotAction]) -> None:
|
| 320 |
+
for action in actions:
|
| 321 |
+
self.validate(action)
|
| 322 |
+
|
| 323 |
+
def capabilities(self, available: set[str]) -> list[CopilotActionCapability]:
|
| 324 |
+
return [
|
| 325 |
+
CopilotActionCapability(
|
| 326 |
+
type=item.type,
|
| 327 |
+
description=item.description,
|
| 328 |
+
required_permission=item.required_permission,
|
| 329 |
+
required_capability=item.required_capability,
|
| 330 |
+
destructive=item.destructive,
|
| 331 |
+
external_side_effect=item.external_side_effect,
|
| 332 |
+
requires_confirmation=item.requires_confirmation,
|
| 333 |
+
available=item.required_capability in available,
|
| 334 |
+
)
|
| 335 |
+
for item in ACTION_DEFINITIONS
|
| 336 |
+
]
|
| 337 |
+
|
| 338 |
+
async def execute(
|
| 339 |
+
self,
|
| 340 |
+
action: CopilotAction,
|
| 341 |
+
*,
|
| 342 |
+
workspace_id: str,
|
| 343 |
+
user_id: str,
|
| 344 |
+
api_key_id: str,
|
| 345 |
+
request_id: str,
|
| 346 |
+
run_id: str,
|
| 347 |
+
permissions: frozenset[str],
|
| 348 |
+
available_capabilities: set[str],
|
| 349 |
+
) -> CopilotActionResult:
|
| 350 |
+
definition = self.validate(action)
|
| 351 |
+
if definition.required_permission not in permissions and "admin" not in permissions:
|
| 352 |
+
raise CopilotPermissionError(
|
| 353 |
+
f"Permission '{definition.required_permission}' is required."
|
| 354 |
+
)
|
| 355 |
+
if definition.required_capability not in available_capabilities:
|
| 356 |
+
raise CopilotCapabilityError(
|
| 357 |
+
f"Capability '{definition.required_capability}' is unavailable."
|
| 358 |
+
)
|
| 359 |
+
if isinstance(action, EditorRenderAction) and not (
|
| 360 |
+
"jobs:create" in permissions or "admin" in permissions
|
| 361 |
+
):
|
| 362 |
+
raise CopilotPermissionError("Permission 'jobs:create' is required.")
|
| 363 |
+
if isinstance(action, TemplateApplyAction) and not (
|
| 364 |
+
"projects:update" in permissions or "admin" in permissions
|
| 365 |
+
):
|
| 366 |
+
raise CopilotPermissionError("Permission 'projects:update' is required.")
|
| 367 |
+
if isinstance(action, TemplateCreateProjectAction) and not (
|
| 368 |
+
"projects:create" in permissions or "admin" in permissions
|
| 369 |
+
):
|
| 370 |
+
raise CopilotPermissionError("Permission 'projects:create' is required.")
|
| 371 |
+
if (
|
| 372 |
+
isinstance(action, (TemplateApplyAction, TemplateCreateProjectAction))
|
| 373 |
+
and any(
|
| 374 |
+
binding.asset_id is not None for binding in action.arguments.slot_bindings.values()
|
| 375 |
+
)
|
| 376 |
+
and not ("assets:read" in permissions or "admin" in permissions)
|
| 377 |
+
):
|
| 378 |
+
raise CopilotPermissionError("Permission 'assets:read' is required.")
|
| 379 |
+
if isinstance(
|
| 380 |
+
action,
|
| 381 |
+
(AiGenerateVideoAction, EditorAddClipAction),
|
| 382 |
+
) and not ("assets:read" in permissions or "admin" in permissions):
|
| 383 |
+
raise CopilotPermissionError("Permission 'assets:read' is required.")
|
| 384 |
+
if (
|
| 385 |
+
isinstance(action, AiGenerateImageAction)
|
| 386 |
+
and action.arguments.source_asset_id is not None
|
| 387 |
+
and not ("assets:read" in permissions or "admin" in permissions)
|
| 388 |
+
):
|
| 389 |
+
raise CopilotPermissionError("Permission 'assets:read' is required.")
|
| 390 |
+
if isinstance(action, ProjectOpenAction):
|
| 391 |
+
project = await self.projects.get(
|
| 392 |
+
workspace_id=workspace_id,
|
| 393 |
+
user_id=user_id,
|
| 394 |
+
project_id=str(action.arguments.project_id),
|
| 395 |
+
)
|
| 396 |
+
return self._success(action, "Project is ready to open.", "project", project.id)
|
| 397 |
+
if isinstance(action, AssetSelectAction):
|
| 398 |
+
asset = await self.assets.get_owned_by_id(
|
| 399 |
+
workspace_id=workspace_id,
|
| 400 |
+
user_id=user_id,
|
| 401 |
+
asset_id=str(action.arguments.asset_id),
|
| 402 |
+
)
|
| 403 |
+
if action.arguments.project_id is not None and asset.project_id != str(
|
| 404 |
+
action.arguments.project_id
|
| 405 |
+
):
|
| 406 |
+
raise CopilotInvalidRequestError(
|
| 407 |
+
"The selected asset does not belong to the selected project."
|
| 408 |
+
)
|
| 409 |
+
return self._success(action, "Asset is ready to open.", "asset", asset.id)
|
| 410 |
+
if isinstance(
|
| 411 |
+
action,
|
| 412 |
+
(
|
| 413 |
+
PublishingValidateAction,
|
| 414 |
+
PublishingCreatePostAction,
|
| 415 |
+
PublishingScheduleAction,
|
| 416 |
+
PublishingPublishAction,
|
| 417 |
+
PublishingCancelAction,
|
| 418 |
+
),
|
| 419 |
+
):
|
| 420 |
+
if self.publishing is None or self.scheduling is None:
|
| 421 |
+
raise CopilotCapabilityError("Publishing orchestration is unavailable.")
|
| 422 |
+
if isinstance(action, PublishingValidateAction):
|
| 423 |
+
result = await self.publishing.validate_post_targets(
|
| 424 |
+
workspace_id, str(action.arguments.post_id)
|
| 425 |
+
)
|
| 426 |
+
return self._success(
|
| 427 |
+
action,
|
| 428 |
+
(
|
| 429 |
+
"Publishing targets are valid."
|
| 430 |
+
if result.valid
|
| 431 |
+
else "Publishing validation found issues."
|
| 432 |
+
),
|
| 433 |
+
"social_post",
|
| 434 |
+
result.post_id,
|
| 435 |
+
)
|
| 436 |
+
if isinstance(action, PublishingCreatePostAction):
|
| 437 |
+
result = await self.publishing.create(
|
| 438 |
+
workspace_id=workspace_id,
|
| 439 |
+
user_id=user_id,
|
| 440 |
+
payload=action.arguments.post,
|
| 441 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 442 |
+
)
|
| 443 |
+
return self._success(
|
| 444 |
+
action, "Publishing draft was created.", "social_post", result.id
|
| 445 |
+
)
|
| 446 |
+
if isinstance(action, PublishingScheduleAction):
|
| 447 |
+
validation = await self.publishing.validate_post_targets(
|
| 448 |
+
workspace_id, str(action.arguments.post_id)
|
| 449 |
+
)
|
| 450 |
+
if not validation.valid:
|
| 451 |
+
raise CopilotInvalidRequestError(
|
| 452 |
+
"Publishing validation must pass before scheduling."
|
| 453 |
+
)
|
| 454 |
+
result = await self.scheduling.schedule(
|
| 455 |
+
workspace_id, str(action.arguments.post_id), action.arguments.schedule
|
| 456 |
+
)
|
| 457 |
+
return self._success(
|
| 458 |
+
action, "Publishing was scheduled.", "social_schedule", result.id
|
| 459 |
+
)
|
| 460 |
+
if isinstance(action, PublishingPublishAction):
|
| 461 |
+
jobs = await self.publishing.queue(
|
| 462 |
+
workspace_id,
|
| 463 |
+
str(action.arguments.post_id),
|
| 464 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 465 |
+
)
|
| 466 |
+
return self._success(
|
| 467 |
+
action,
|
| 468 |
+
f"Queued {len(jobs)} publishing targets.",
|
| 469 |
+
"social_post",
|
| 470 |
+
str(action.arguments.post_id),
|
| 471 |
+
)
|
| 472 |
+
result = await self.publishing.cancel(workspace_id, str(action.arguments.post_id))
|
| 473 |
+
return self._success(
|
| 474 |
+
action,
|
| 475 |
+
"Cancellation was applied to eligible publishing targets.",
|
| 476 |
+
"social_post",
|
| 477 |
+
result.id,
|
| 478 |
+
)
|
| 479 |
+
if isinstance(action, (AnalyticsOverviewAction, AnalyticsSyncAction)):
|
| 480 |
+
if self.analytics is None:
|
| 481 |
+
raise CopilotCapabilityError("Analytics orchestration is unavailable.")
|
| 482 |
+
if isinstance(action, AnalyticsOverviewAction):
|
| 483 |
+
result = await self.analytics.overview(
|
| 484 |
+
workspace_id,
|
| 485 |
+
AnalyticsQuery(
|
| 486 |
+
project_id=(
|
| 487 |
+
str(action.arguments.project_id)
|
| 488 |
+
if action.arguments.project_id
|
| 489 |
+
else None
|
| 490 |
+
),
|
| 491 |
+
provider=action.arguments.provider,
|
| 492 |
+
metric=action.arguments.metric,
|
| 493 |
+
timezone=action.arguments.timezone,
|
| 494 |
+
sort=action.arguments.metric,
|
| 495 |
+
),
|
| 496 |
+
)
|
| 497 |
+
freshness = result.freshness.status
|
| 498 |
+
return self._success(
|
| 499 |
+
action,
|
| 500 |
+
f"Analytics overview is {freshness}; unavailable metrics were not inferred.",
|
| 501 |
+
"analytics_overview",
|
| 502 |
+
(
|
| 503 |
+
str(action.arguments.project_id)
|
| 504 |
+
if action.arguments.project_id
|
| 505 |
+
else workspace_id
|
| 506 |
+
),
|
| 507 |
+
)
|
| 508 |
+
result = await self.analytics.create_sync(
|
| 509 |
+
workspace_id,
|
| 510 |
+
user_id,
|
| 511 |
+
AnalyticsSyncRequest(
|
| 512 |
+
project_id=(
|
| 513 |
+
str(action.arguments.project_id) if action.arguments.project_id else None
|
| 514 |
+
),
|
| 515 |
+
provider=action.arguments.provider,
|
| 516 |
+
timezone=action.arguments.timezone,
|
| 517 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 518 |
+
),
|
| 519 |
+
)
|
| 520 |
+
return self._success(
|
| 521 |
+
action, "Analytics synchronization was queued.", "analytics_sync", result.id
|
| 522 |
+
)
|
| 523 |
+
if isinstance(action, TemplateSearchAction):
|
| 524 |
+
result = await self.templates.list(
|
| 525 |
+
workspace_id=workspace_id,
|
| 526 |
+
user_id=user_id,
|
| 527 |
+
search=action.arguments.query,
|
| 528 |
+
category=action.arguments.category,
|
| 529 |
+
aspect_ratio=None,
|
| 530 |
+
min_duration_ms=None,
|
| 531 |
+
max_duration_ms=None,
|
| 532 |
+
media_type=None,
|
| 533 |
+
visibility=None,
|
| 534 |
+
status=None,
|
| 535 |
+
capability=None,
|
| 536 |
+
available_only=False,
|
| 537 |
+
offset=0,
|
| 538 |
+
limit=24,
|
| 539 |
+
)
|
| 540 |
+
return self._success(
|
| 541 |
+
action,
|
| 542 |
+
f"Found {result.total} visible templates.",
|
| 543 |
+
"template_search",
|
| 544 |
+
action.arguments.query,
|
| 545 |
+
)
|
| 546 |
+
if isinstance(action, TemplateGetAction):
|
| 547 |
+
template = await self.templates.get(
|
| 548 |
+
workspace_id=workspace_id,
|
| 549 |
+
user_id=user_id,
|
| 550 |
+
template_id=str(action.arguments.template_id),
|
| 551 |
+
)
|
| 552 |
+
return self._success(action, "Template is ready to open.", "template", template.id)
|
| 553 |
+
if isinstance(action, TemplateApplyAction):
|
| 554 |
+
result = await self.templates.apply(
|
| 555 |
+
workspace_id=workspace_id,
|
| 556 |
+
user_id=user_id,
|
| 557 |
+
api_key_id=api_key_id,
|
| 558 |
+
request_id=request_id,
|
| 559 |
+
template_id=str(action.arguments.template_id),
|
| 560 |
+
payload=TemplateApply(
|
| 561 |
+
project_id=action.arguments.project_id,
|
| 562 |
+
template_version_id=action.arguments.template_version_id,
|
| 563 |
+
slot_bindings=action.arguments.slot_bindings,
|
| 564 |
+
),
|
| 565 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 566 |
+
instantiate=False,
|
| 567 |
+
)
|
| 568 |
+
return self._success(
|
| 569 |
+
action, "Template was applied to the project.", "project", result.project_id
|
| 570 |
+
)
|
| 571 |
+
if isinstance(action, TemplateCreateProjectAction):
|
| 572 |
+
result = await self.templates.apply(
|
| 573 |
+
workspace_id=workspace_id,
|
| 574 |
+
user_id=user_id,
|
| 575 |
+
api_key_id=api_key_id,
|
| 576 |
+
request_id=request_id,
|
| 577 |
+
template_id=str(action.arguments.template_id),
|
| 578 |
+
payload=TemplateInstantiate(
|
| 579 |
+
project_name=action.arguments.project_name,
|
| 580 |
+
template_version_id=action.arguments.template_version_id,
|
| 581 |
+
slot_bindings=action.arguments.slot_bindings,
|
| 582 |
+
),
|
| 583 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 584 |
+
instantiate=True,
|
| 585 |
+
)
|
| 586 |
+
return self._success(
|
| 587 |
+
action, "Project was created from the template.", "project", result.project_id
|
| 588 |
+
)
|
| 589 |
+
if isinstance(action, AiGenerateImageAction):
|
| 590 |
+
job = await self.ai.create(
|
| 591 |
+
workspace_id=workspace_id,
|
| 592 |
+
user_id=user_id,
|
| 593 |
+
api_key_id=api_key_id,
|
| 594 |
+
request_id=request_id,
|
| 595 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 596 |
+
payload=AiGenerateImageRequest(
|
| 597 |
+
operation="generate_image",
|
| 598 |
+
prompt=action.arguments.prompt,
|
| 599 |
+
model=action.arguments.model,
|
| 600 |
+
project_id=action.arguments.project_id,
|
| 601 |
+
source_asset_ids=(
|
| 602 |
+
[action.arguments.source_asset_id]
|
| 603 |
+
if action.arguments.source_asset_id
|
| 604 |
+
else []
|
| 605 |
+
),
|
| 606 |
+
),
|
| 607 |
+
)
|
| 608 |
+
return self._success(
|
| 609 |
+
action, "Image generation was submitted.", "ai_generation", job.generation_id
|
| 610 |
+
)
|
| 611 |
+
if isinstance(action, AiGenerateVideoAction):
|
| 612 |
+
job = await self.ai.create(
|
| 613 |
+
workspace_id=workspace_id,
|
| 614 |
+
user_id=user_id,
|
| 615 |
+
api_key_id=api_key_id,
|
| 616 |
+
request_id=request_id,
|
| 617 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 618 |
+
payload=AiGenerateVideoRequest(
|
| 619 |
+
operation="generate_video",
|
| 620 |
+
prompt=action.arguments.prompt,
|
| 621 |
+
model=action.arguments.model,
|
| 622 |
+
project_id=action.arguments.project_id,
|
| 623 |
+
source_asset_ids=[action.arguments.source_asset_id],
|
| 624 |
+
),
|
| 625 |
+
)
|
| 626 |
+
return self._success(
|
| 627 |
+
action, "Video generation was submitted.", "ai_generation", job.generation_id
|
| 628 |
+
)
|
| 629 |
+
if isinstance(action, EditorRenderAction):
|
| 630 |
+
render = await self.renders.create(
|
| 631 |
+
workspace_id=workspace_id,
|
| 632 |
+
user_id=user_id,
|
| 633 |
+
api_key_id=api_key_id,
|
| 634 |
+
request_id=request_id,
|
| 635 |
+
project_id=str(action.arguments.project_id),
|
| 636 |
+
idempotency_key=f"copilot:{run_id}:{action.id}",
|
| 637 |
+
payload=ProjectRenderCreate(editor_revision=action.arguments.expected_revision),
|
| 638 |
+
)
|
| 639 |
+
return self._success(action, "Render was submitted.", "project_render", render.id)
|
| 640 |
+
if isinstance(
|
| 641 |
+
action,
|
| 642 |
+
(
|
| 643 |
+
EditorSplitClipAction,
|
| 644 |
+
EditorDeleteClipAction,
|
| 645 |
+
EditorSetDurationAction,
|
| 646 |
+
EditorAddClipAction,
|
| 647 |
+
),
|
| 648 |
+
):
|
| 649 |
+
revision = await self._execute_editor_action(
|
| 650 |
+
action,
|
| 651 |
+
workspace_id=workspace_id,
|
| 652 |
+
user_id=user_id,
|
| 653 |
+
api_key_id=api_key_id,
|
| 654 |
+
request_id=request_id,
|
| 655 |
+
)
|
| 656 |
+
return self._success(
|
| 657 |
+
action,
|
| 658 |
+
f"Editor revision {revision} was saved.",
|
| 659 |
+
"project_editor_revision",
|
| 660 |
+
str(revision),
|
| 661 |
+
)
|
| 662 |
+
raise CopilotInvalidRequestError("Copilot action type is not registered.")
|
| 663 |
+
|
| 664 |
+
async def _execute_editor_action(
|
| 665 |
+
self,
|
| 666 |
+
action,
|
| 667 |
+
*,
|
| 668 |
+
workspace_id: str,
|
| 669 |
+
user_id: str,
|
| 670 |
+
api_key_id: str,
|
| 671 |
+
request_id: str,
|
| 672 |
+
) -> int:
|
| 673 |
+
project_id = str(action.arguments.project_id)
|
| 674 |
+
current = await self.editor.get(
|
| 675 |
+
workspace_id=workspace_id, user_id=user_id, project_id=project_id
|
| 676 |
+
)
|
| 677 |
+
if current.revision != action.arguments.expected_revision:
|
| 678 |
+
raise CopilotInvalidRequestError(
|
| 679 |
+
"The editor changed after this plan was created. Create a new plan."
|
| 680 |
+
)
|
| 681 |
+
document = current.state.model_copy(deep=True)
|
| 682 |
+
if isinstance(action, EditorSplitClipAction):
|
| 683 |
+
self._split(document, action.arguments.clip_id, action.arguments.at_ms)
|
| 684 |
+
elif isinstance(action, EditorDeleteClipAction):
|
| 685 |
+
self._delete(document, action.arguments.clip_id)
|
| 686 |
+
elif isinstance(action, EditorSetDurationAction):
|
| 687 |
+
self._set_duration(document, action.arguments.clip_id, action.arguments.duration_ms)
|
| 688 |
+
elif isinstance(action, EditorAddClipAction):
|
| 689 |
+
await self._add_clip(
|
| 690 |
+
document,
|
| 691 |
+
workspace_id=workspace_id,
|
| 692 |
+
user_id=user_id,
|
| 693 |
+
project_id=project_id,
|
| 694 |
+
asset_id=str(action.arguments.asset_id),
|
| 695 |
+
duration_ms=action.arguments.duration_ms,
|
| 696 |
+
)
|
| 697 |
+
validated = EditorDocument.model_validate(document.model_dump(by_alias=True))
|
| 698 |
+
saved = await self.editor.save(
|
| 699 |
+
workspace_id=workspace_id,
|
| 700 |
+
user_id=user_id,
|
| 701 |
+
api_key_id=api_key_id,
|
| 702 |
+
request_id=request_id,
|
| 703 |
+
project_id=project_id,
|
| 704 |
+
payload=EditorSaveRequest(
|
| 705 |
+
expected_revision=current.revision,
|
| 706 |
+
schema_version=validated.schema_version,
|
| 707 |
+
state=validated,
|
| 708 |
+
),
|
| 709 |
+
)
|
| 710 |
+
return saved.revision
|
| 711 |
+
|
| 712 |
+
@staticmethod
|
| 713 |
+
def _find_clip(document: EditorDocument, clip_id: str):
|
| 714 |
+
for track in document.timeline.tracks:
|
| 715 |
+
for index, clip in enumerate(track.clips):
|
| 716 |
+
if clip.id == clip_id:
|
| 717 |
+
return track, index, clip
|
| 718 |
+
raise CopilotInvalidRequestError("The selected clip no longer exists.")
|
| 719 |
+
|
| 720 |
+
def _split(self, document: EditorDocument, clip_id: str, at_ms: int) -> None:
|
| 721 |
+
track, index, clip = self._find_clip(document, clip_id)
|
| 722 |
+
relative = at_ms - clip.start_ms
|
| 723 |
+
if relative <= 0 or relative >= clip.duration_ms:
|
| 724 |
+
raise CopilotInvalidRequestError("The split point must fall inside the selected clip.")
|
| 725 |
+
right = clip.model_copy(deep=True)
|
| 726 |
+
right.id = str(uuid4())
|
| 727 |
+
right.start_ms = at_ms
|
| 728 |
+
right.duration_ms = clip.duration_ms - relative
|
| 729 |
+
clip.duration_ms = relative
|
| 730 |
+
if isinstance(clip, SourceClip) and isinstance(right, SourceClip):
|
| 731 |
+
right.source_start_ms = clip.source_start_ms + relative
|
| 732 |
+
right.source_duration_ms = right.duration_ms
|
| 733 |
+
clip.source_duration_ms = clip.duration_ms
|
| 734 |
+
track.clips.insert(index + 1, right)
|
| 735 |
+
|
| 736 |
+
def _delete(self, document: EditorDocument, clip_id: str) -> None:
|
| 737 |
+
track, index, _ = self._find_clip(document, clip_id)
|
| 738 |
+
track.clips.pop(index)
|
| 739 |
+
document.timeline.transitions = [
|
| 740 |
+
item
|
| 741 |
+
for item in document.timeline.transitions
|
| 742 |
+
if item.from_clip_id != clip_id and item.to_clip_id != clip_id
|
| 743 |
+
]
|
| 744 |
+
for candidate in document.timeline.tracks:
|
| 745 |
+
candidate.clips = [
|
| 746 |
+
clip
|
| 747 |
+
for clip in candidate.clips
|
| 748 |
+
if not (isinstance(clip, EffectClip) and clip.target_clip_id == clip_id)
|
| 749 |
+
]
|
| 750 |
+
|
| 751 |
+
def _set_duration(self, document: EditorDocument, clip_id: str, duration_ms: int) -> None:
|
| 752 |
+
_, _, clip = self._find_clip(document, clip_id)
|
| 753 |
+
if isinstance(clip, SourceClip) and not (
|
| 754 |
+
isinstance(clip, MediaClip) and clip.media_type == "image"
|
| 755 |
+
):
|
| 756 |
+
raise CopilotInvalidRequestError(
|
| 757 |
+
"Only image or non-source clips can be extended without media analysis."
|
| 758 |
+
)
|
| 759 |
+
clip.duration_ms = duration_ms
|
| 760 |
+
if isinstance(clip, SourceClip):
|
| 761 |
+
clip.source_duration_ms = duration_ms
|
| 762 |
+
|
| 763 |
+
async def _add_clip(
|
| 764 |
+
self,
|
| 765 |
+
document: EditorDocument,
|
| 766 |
+
*,
|
| 767 |
+
workspace_id: str,
|
| 768 |
+
user_id: str,
|
| 769 |
+
project_id: str,
|
| 770 |
+
asset_id: str,
|
| 771 |
+
duration_ms: int,
|
| 772 |
+
) -> None:
|
| 773 |
+
asset = await self.assets.get_owned_by_id(
|
| 774 |
+
workspace_id=workspace_id, user_id=user_id, asset_id=asset_id
|
| 775 |
+
)
|
| 776 |
+
if asset.project_id != project_id:
|
| 777 |
+
raise CopilotInvalidRequestError("The selected asset is not attached to this project.")
|
| 778 |
+
mime = asset.mime_type
|
| 779 |
+
if mime.startswith("audio/"):
|
| 780 |
+
track_type = "audio"
|
| 781 |
+
kind = "audio"
|
| 782 |
+
elif mime.startswith("video/"):
|
| 783 |
+
track_type = "video"
|
| 784 |
+
kind = "video"
|
| 785 |
+
elif mime.startswith("image/"):
|
| 786 |
+
track_type = "video"
|
| 787 |
+
kind = "image"
|
| 788 |
+
else:
|
| 789 |
+
raise CopilotInvalidRequestError("This asset type cannot be added to the timeline.")
|
| 790 |
+
track = next(
|
| 791 |
+
(item for item in document.timeline.tracks if item.type == track_type),
|
| 792 |
+
None,
|
| 793 |
+
)
|
| 794 |
+
if track is None:
|
| 795 |
+
track = Track(
|
| 796 |
+
id=str(uuid4()),
|
| 797 |
+
type=track_type,
|
| 798 |
+
name="Copilot media",
|
| 799 |
+
order=len(document.timeline.tracks),
|
| 800 |
+
muted=False,
|
| 801 |
+
locked=False,
|
| 802 |
+
visible=True,
|
| 803 |
+
clips=[],
|
| 804 |
+
)
|
| 805 |
+
document.timeline.tracks.append(track)
|
| 806 |
+
metadata = asset.metadata_json or {}
|
| 807 |
+
source_duration = metadata.get("duration_ms")
|
| 808 |
+
if source_duration is None and isinstance(metadata.get("duration"), (int, float)):
|
| 809 |
+
source_duration = round(float(metadata["duration"]) * 1_000)
|
| 810 |
+
clip_duration = duration_ms if kind == "image" else source_duration
|
| 811 |
+
if not isinstance(clip_duration, int) or clip_duration <= 0:
|
| 812 |
+
raise CopilotInvalidRequestError("The asset has no validated duration metadata.")
|
| 813 |
+
common = {
|
| 814 |
+
"id": str(uuid4()),
|
| 815 |
+
"trackId": track.id,
|
| 816 |
+
"label": asset.filename[:500],
|
| 817 |
+
"startMs": document.duration_ms(),
|
| 818 |
+
"durationMs": clip_duration,
|
| 819 |
+
"visible": True,
|
| 820 |
+
"opacity": 1,
|
| 821 |
+
"metadata": {},
|
| 822 |
+
"assetId": asset.id,
|
| 823 |
+
"sourceStartMs": 0,
|
| 824 |
+
"sourceDurationMs": clip_duration,
|
| 825 |
+
}
|
| 826 |
+
if kind == "audio":
|
| 827 |
+
track.clips.append(AudioClip(**common, kind="audio", volume=1, fadeInMs=0, fadeOutMs=0))
|
| 828 |
+
else:
|
| 829 |
+
track.clips.append(
|
| 830 |
+
MediaClip(
|
| 831 |
+
**common,
|
| 832 |
+
kind="media",
|
| 833 |
+
mediaType=kind,
|
| 834 |
+
transform=ClipTransform(x=0, y=0, scaleX=1, scaleY=1, rotation=0),
|
| 835 |
+
volume=1,
|
| 836 |
+
)
|
| 837 |
+
)
|
| 838 |
+
|
| 839 |
+
@staticmethod
|
| 840 |
+
def _success(
|
| 841 |
+
action: CopilotAction,
|
| 842 |
+
summary: str,
|
| 843 |
+
resource_type: str,
|
| 844 |
+
resource_id: str,
|
| 845 |
+
) -> CopilotActionResult:
|
| 846 |
+
return CopilotActionResult(
|
| 847 |
+
action_id=action.id,
|
| 848 |
+
action_type=action.type,
|
| 849 |
+
status="completed",
|
| 850 |
+
summary=summary,
|
| 851 |
+
resource_type=resource_type,
|
| 852 |
+
resource_id=resource_id,
|
| 853 |
+
)
|
app/copilot/api.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Header, Path, Query, Request, status
|
| 6 |
+
|
| 7 |
+
from app.copilot.schemas import (
|
| 8 |
+
CopilotCapabilities,
|
| 9 |
+
CopilotContextInput,
|
| 10 |
+
CopilotExecuteRequest,
|
| 11 |
+
CopilotRun,
|
| 12 |
+
CopilotRunCreate,
|
| 13 |
+
CopilotRunList,
|
| 14 |
+
)
|
| 15 |
+
from app.security.errors import ForbiddenError
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/v1/copilot", tags=["copilot"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _identity(request: Request):
|
| 21 |
+
context = request.state.auth
|
| 22 |
+
if not context.workspace_id or not context.user_id:
|
| 23 |
+
raise ForbiddenError
|
| 24 |
+
return context
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/capabilities", response_model=CopilotCapabilities)
|
| 28 |
+
async def capabilities(request: Request, context: CopilotContextInput) -> CopilotCapabilities:
|
| 29 |
+
identity = _identity(request)
|
| 30 |
+
return await request.app.state.container.copilot.capabilities(
|
| 31 |
+
workspace_id=identity.workspace_id,
|
| 32 |
+
user_id=identity.user_id,
|
| 33 |
+
context=context,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.post("/runs", response_model=CopilotRun, status_code=status.HTTP_201_CREATED)
|
| 38 |
+
async def create_run(
|
| 39 |
+
request: Request,
|
| 40 |
+
payload: CopilotRunCreate,
|
| 41 |
+
idempotency_key: Annotated[str, Header(alias="Idempotency-Key", min_length=8, max_length=255)],
|
| 42 |
+
) -> CopilotRun:
|
| 43 |
+
identity = _identity(request)
|
| 44 |
+
return await request.app.state.container.copilot.create_run(
|
| 45 |
+
workspace_id=identity.workspace_id,
|
| 46 |
+
user_id=identity.user_id,
|
| 47 |
+
api_key_id=identity.api_key_id,
|
| 48 |
+
request_id=request.state.request_id,
|
| 49 |
+
payload=payload,
|
| 50 |
+
idempotency_key=idempotency_key,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("/runs", response_model=CopilotRunList)
|
| 55 |
+
async def list_runs(
|
| 56 |
+
request: Request,
|
| 57 |
+
offset: Annotated[int, Query(ge=0)] = 0,
|
| 58 |
+
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
| 59 |
+
) -> CopilotRunList:
|
| 60 |
+
identity = _identity(request)
|
| 61 |
+
return await request.app.state.container.copilot.list(
|
| 62 |
+
workspace_id=identity.workspace_id,
|
| 63 |
+
user_id=identity.user_id,
|
| 64 |
+
offset=offset,
|
| 65 |
+
limit=limit,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.get("/history", response_model=CopilotRunList)
|
| 70 |
+
async def history(
|
| 71 |
+
request: Request,
|
| 72 |
+
offset: Annotated[int, Query(ge=0)] = 0,
|
| 73 |
+
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
| 74 |
+
) -> CopilotRunList:
|
| 75 |
+
return await list_runs(request, offset, limit)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.get("/runs/{run_id}", response_model=CopilotRun)
|
| 79 |
+
async def get_run(
|
| 80 |
+
request: Request,
|
| 81 |
+
run_id: Annotated[str, Path(min_length=36, max_length=36)],
|
| 82 |
+
) -> CopilotRun:
|
| 83 |
+
identity = _identity(request)
|
| 84 |
+
return await request.app.state.container.copilot.get(
|
| 85 |
+
workspace_id=identity.workspace_id,
|
| 86 |
+
user_id=identity.user_id,
|
| 87 |
+
run_id=run_id,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/runs/{run_id}/execute", response_model=CopilotRun)
|
| 92 |
+
async def execute_run(
|
| 93 |
+
request: Request,
|
| 94 |
+
payload: CopilotExecuteRequest,
|
| 95 |
+
run_id: Annotated[str, Path(min_length=36, max_length=36)],
|
| 96 |
+
) -> CopilotRun:
|
| 97 |
+
identity = _identity(request)
|
| 98 |
+
return await request.app.state.container.copilot.execute(
|
| 99 |
+
workspace_id=identity.workspace_id,
|
| 100 |
+
user_id=identity.user_id,
|
| 101 |
+
api_key_id=identity.api_key_id,
|
| 102 |
+
request_id=request.state.request_id,
|
| 103 |
+
run_id=run_id,
|
| 104 |
+
payload=payload,
|
| 105 |
+
permissions=identity.scopes,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.post("/runs/{run_id}/cancel", response_model=CopilotRun)
|
| 110 |
+
async def cancel_run(
|
| 111 |
+
request: Request,
|
| 112 |
+
run_id: Annotated[str, Path(min_length=36, max_length=36)],
|
| 113 |
+
) -> CopilotRun:
|
| 114 |
+
identity = _identity(request)
|
| 115 |
+
return await request.app.state.container.copilot.cancel(
|
| 116 |
+
workspace_id=identity.workspace_id,
|
| 117 |
+
user_id=identity.user_id,
|
| 118 |
+
api_key_id=identity.api_key_id,
|
| 119 |
+
request_id=request.state.request_id,
|
| 120 |
+
run_id=run_id,
|
| 121 |
+
)
|
app/copilot/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.exceptions import MediaAPIError
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class CopilotInvalidRequestError(MediaAPIError):
|
| 5 |
+
code = "COPILOT_INVALID_REQUEST"
|
| 6 |
+
status_code = 422
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CopilotUnsupportedIntentError(MediaAPIError):
|
| 10 |
+
code = "COPILOT_UNSUPPORTED_INTENT"
|
| 11 |
+
status_code = 422
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class CopilotRunNotFoundError(MediaAPIError):
|
| 15 |
+
code = "COPILOT_RUN_NOT_FOUND"
|
| 16 |
+
status_code = 404
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CopilotRunConflictError(MediaAPIError):
|
| 20 |
+
code = "COPILOT_RUN_CONFLICT"
|
| 21 |
+
status_code = 409
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CopilotConfirmationRequiredError(MediaAPIError):
|
| 25 |
+
code = "COPILOT_CONFIRMATION_REQUIRED"
|
| 26 |
+
status_code = 409
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class CopilotPermissionError(MediaAPIError):
|
| 30 |
+
code = "COPILOT_ACTION_FORBIDDEN"
|
| 31 |
+
status_code = 403
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class CopilotCapabilityError(MediaAPIError):
|
| 35 |
+
code = "COPILOT_CAPABILITY_UNAVAILABLE"
|
| 36 |
+
status_code = 422
|
app/copilot/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import (
|
| 7 |
+
JSON,
|
| 8 |
+
CheckConstraint,
|
| 9 |
+
DateTime,
|
| 10 |
+
ForeignKey,
|
| 11 |
+
Index,
|
| 12 |
+
String,
|
| 13 |
+
Text,
|
| 14 |
+
UniqueConstraint,
|
| 15 |
+
)
|
| 16 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 17 |
+
|
| 18 |
+
from app.security.models import Base, utcnow
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CopilotRunRecord(Base):
|
| 22 |
+
__tablename__ = "copilot_runs"
|
| 23 |
+
__table_args__ = (
|
| 24 |
+
UniqueConstraint(
|
| 25 |
+
"workspace_id", "idempotency_key", name="uq_copilot_run_workspace_idempotency"
|
| 26 |
+
),
|
| 27 |
+
CheckConstraint(
|
| 28 |
+
"status in ('plan_ready','blocked','executing','completed','partial','failed','cancelled')",
|
| 29 |
+
name="ck_copilot_run_status",
|
| 30 |
+
),
|
| 31 |
+
Index("ix_copilot_runs_workspace_created", "workspace_id", "created_at"),
|
| 32 |
+
Index("ix_copilot_runs_workspace_status", "workspace_id", "status"),
|
| 33 |
+
Index("ix_copilot_runs_project_created", "project_id", "created_at"),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 37 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 38 |
+
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 39 |
+
)
|
| 40 |
+
user_id: Mapped[str] = mapped_column(
|
| 41 |
+
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
| 42 |
+
)
|
| 43 |
+
project_id: Mapped[str | None] = mapped_column(
|
| 44 |
+
String(36), ForeignKey("projects.id", ondelete="RESTRICT")
|
| 45 |
+
)
|
| 46 |
+
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 47 |
+
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 48 |
+
request_text: Mapped[str] = mapped_column(Text, nullable=False)
|
| 49 |
+
context_json: Mapped[dict[str, object]] = mapped_column("context", JSON, nullable=False)
|
| 50 |
+
plan_json: Mapped[dict[str, object]] = mapped_column("plan", JSON, nullable=False)
|
| 51 |
+
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 52 |
+
current_action_id: Mapped[str | None] = mapped_column(String(64))
|
| 53 |
+
results_json: Mapped[list[dict[str, object]]] = mapped_column(
|
| 54 |
+
"results", JSON, nullable=False, default=list
|
| 55 |
+
)
|
| 56 |
+
summary: Mapped[str | None] = mapped_column(Text)
|
| 57 |
+
error_code: Mapped[str | None] = mapped_column(String(100))
|
| 58 |
+
error_message: Mapped[str | None] = mapped_column(Text)
|
| 59 |
+
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 60 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 61 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 62 |
+
)
|
| 63 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 64 |
+
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
| 65 |
+
)
|
| 66 |
+
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
app/copilot/planner.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from app.copilot.schemas import (
|
| 7 |
+
AiGenerateImageAction,
|
| 8 |
+
AiGenerateImageArguments,
|
| 9 |
+
AiGenerateVideoAction,
|
| 10 |
+
AiGenerateVideoArguments,
|
| 11 |
+
AnalyticsOverviewAction,
|
| 12 |
+
AnalyticsOverviewArguments,
|
| 13 |
+
AnalyticsSyncAction,
|
| 14 |
+
AnalyticsSyncArguments,
|
| 15 |
+
AssetSelectAction,
|
| 16 |
+
AssetSelectArguments,
|
| 17 |
+
ShareProjectAction,
|
| 18 |
+
ShareProjectArguments,
|
| 19 |
+
CopilotContext,
|
| 20 |
+
CopilotPlan,
|
| 21 |
+
EditorAddClipAction,
|
| 22 |
+
EditorAddClipArguments,
|
| 23 |
+
EditorDeleteClipAction,
|
| 24 |
+
EditorDeleteClipArguments,
|
| 25 |
+
EditorRenderAction,
|
| 26 |
+
EditorRenderArguments,
|
| 27 |
+
EditorSetDurationAction,
|
| 28 |
+
EditorSetDurationArguments,
|
| 29 |
+
EditorSplitClipAction,
|
| 30 |
+
EditorSplitClipArguments,
|
| 31 |
+
ProjectOpenAction,
|
| 32 |
+
ProjectOpenArguments,
|
| 33 |
+
PublishingCancelAction,
|
| 34 |
+
PublishingCancelArguments,
|
| 35 |
+
PublishingPublishAction,
|
| 36 |
+
PublishingPublishArguments,
|
| 37 |
+
PublishingValidateAction,
|
| 38 |
+
PublishingValidateArguments,
|
| 39 |
+
TemplateApplyAction,
|
| 40 |
+
TemplateApplyArguments,
|
| 41 |
+
TemplateCreateProjectAction,
|
| 42 |
+
TemplateCreateProjectArguments,
|
| 43 |
+
TemplateGetAction,
|
| 44 |
+
TemplateGetArguments,
|
| 45 |
+
TemplateSearchAction,
|
| 46 |
+
TemplateSearchArguments,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class CopilotPlanner:
|
| 51 |
+
"""Closed deterministic planner used until a text tool-calling model exists."""
|
| 52 |
+
|
| 53 |
+
def plan(self, request: str, context: CopilotContext) -> CopilotPlan:
|
| 54 |
+
normalized = " ".join(request.strip().split())
|
| 55 |
+
lowered = normalized.casefold()
|
| 56 |
+
action_id = str(uuid4())
|
| 57 |
+
project_id = context.project_id
|
| 58 |
+
selected_asset = context.selected_asset_ids[0] if context.selected_asset_ids else None
|
| 59 |
+
selected_clip = context.selected_clip_ids[0] if context.selected_clip_ids else None
|
| 60 |
+
revision = context.editor_summary.revision if context.editor_summary else None
|
| 61 |
+
|
| 62 |
+
unavailable = (
|
| 63 |
+
None
|
| 64 |
+
if "template" in lowered
|
| 65 |
+
else self._requested_unavailable_capability(lowered, context)
|
| 66 |
+
)
|
| 67 |
+
if unavailable:
|
| 68 |
+
return self._blocked(
|
| 69 |
+
normalized,
|
| 70 |
+
f"This request requires {unavailable}, but that capability is not available.",
|
| 71 |
+
unsupported=[unavailable],
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
template_id = self._template_id(lowered)
|
| 75 |
+
publishing_post_id = self._template_id(lowered)
|
| 76 |
+
if "analytics" in lowered and re.search(r"\b(sync|refresh|update)\b", lowered):
|
| 77 |
+
action = AnalyticsSyncAction(
|
| 78 |
+
id=action_id,
|
| 79 |
+
type="analytics.sync",
|
| 80 |
+
arguments=AnalyticsSyncArguments(project_id=project_id),
|
| 81 |
+
reason="Queue a durable synchronization through authorized provider adapters.",
|
| 82 |
+
requires_confirmation=True,
|
| 83 |
+
destructive=False,
|
| 84 |
+
external_side_effect=False,
|
| 85 |
+
required_permission="analytics:sync",
|
| 86 |
+
required_capability="analytics.sync",
|
| 87 |
+
)
|
| 88 |
+
return self._plan(
|
| 89 |
+
"Sync analytics",
|
| 90 |
+
"Queue an idempotent authoritative analytics synchronization.",
|
| 91 |
+
[action],
|
| 92 |
+
)
|
| 93 |
+
if re.search(r"\b(analytics|performance|performing|insights)\b", lowered):
|
| 94 |
+
metric = next(
|
| 95 |
+
(
|
| 96 |
+
item
|
| 97 |
+
for item in (
|
| 98 |
+
"views",
|
| 99 |
+
"impressions",
|
| 100 |
+
"likes",
|
| 101 |
+
"comments",
|
| 102 |
+
"shares",
|
| 103 |
+
"engagement_rate",
|
| 104 |
+
)
|
| 105 |
+
if item.replace("_", " ") in lowered
|
| 106 |
+
),
|
| 107 |
+
"views",
|
| 108 |
+
)
|
| 109 |
+
action = AnalyticsOverviewAction(
|
| 110 |
+
id=action_id,
|
| 111 |
+
type="analytics.overview",
|
| 112 |
+
arguments=AnalyticsOverviewArguments(project_id=project_id, metric=metric),
|
| 113 |
+
reason="Read synchronized provider metrics without inferring unavailable values.",
|
| 114 |
+
requires_confirmation=False,
|
| 115 |
+
destructive=False,
|
| 116 |
+
external_side_effect=False,
|
| 117 |
+
required_permission="analytics:read",
|
| 118 |
+
required_capability="analytics.overview",
|
| 119 |
+
)
|
| 120 |
+
return self._plan(
|
| 121 |
+
"Review analytics",
|
| 122 |
+
"Read authoritative analytics and report freshness explicitly.",
|
| 123 |
+
[action],
|
| 124 |
+
)
|
| 125 |
+
if publishing_post_id and re.search(r"\b(validate|check)\b.*\b(publish|post)\b", lowered):
|
| 126 |
+
action = PublishingValidateAction(
|
| 127 |
+
id=action_id,
|
| 128 |
+
type="publishing.validate",
|
| 129 |
+
arguments=PublishingValidateArguments(post_id=publishing_post_id),
|
| 130 |
+
reason="Validate the existing canonical post and every selected provider target.",
|
| 131 |
+
requires_confirmation=False,
|
| 132 |
+
destructive=False,
|
| 133 |
+
external_side_effect=False,
|
| 134 |
+
required_permission="social:posts:write",
|
| 135 |
+
required_capability="publishing.validate",
|
| 136 |
+
)
|
| 137 |
+
return self._plan(
|
| 138 |
+
"Validate publishing",
|
| 139 |
+
"Run authoritative per-target publishing validation.",
|
| 140 |
+
[action],
|
| 141 |
+
)
|
| 142 |
+
if publishing_post_id and re.search(r"\b(publish|send)\b", lowered):
|
| 143 |
+
action = PublishingPublishAction(
|
| 144 |
+
id=action_id,
|
| 145 |
+
type="publishing.publish",
|
| 146 |
+
arguments=PublishingPublishArguments(post_id=publishing_post_id),
|
| 147 |
+
reason="Publish the explicitly identified canonical post to its already selected accounts.",
|
| 148 |
+
requires_confirmation=True,
|
| 149 |
+
destructive=False,
|
| 150 |
+
external_side_effect=True,
|
| 151 |
+
required_permission="social:posts:publish",
|
| 152 |
+
required_capability="publishing.publish",
|
| 153 |
+
)
|
| 154 |
+
return self._plan(
|
| 155 |
+
"Publish social post",
|
| 156 |
+
"Validate and queue external publishing only after explicit confirmation.",
|
| 157 |
+
[action],
|
| 158 |
+
)
|
| 159 |
+
if publishing_post_id and re.search(r"\bcancel\b.*\b(publish|post)\b", lowered):
|
| 160 |
+
action = PublishingCancelAction(
|
| 161 |
+
id=action_id,
|
| 162 |
+
type="publishing.cancel",
|
| 163 |
+
arguments=PublishingCancelArguments(post_id=publishing_post_id),
|
| 164 |
+
reason="Cancel eligible jobs and request cancellation for in-flight provider work.",
|
| 165 |
+
requires_confirmation=True,
|
| 166 |
+
destructive=False,
|
| 167 |
+
external_side_effect=True,
|
| 168 |
+
required_permission="social:posts:write",
|
| 169 |
+
required_capability="publishing.cancel",
|
| 170 |
+
)
|
| 171 |
+
return self._plan(
|
| 172 |
+
"Cancel publishing",
|
| 173 |
+
"Apply truthful cancellation semantics after confirmation.",
|
| 174 |
+
[action],
|
| 175 |
+
)
|
| 176 |
+
if "template" in lowered and re.search(r"\b(find|search|browse)\b", lowered):
|
| 177 |
+
query = re.sub(
|
| 178 |
+
r"(?i)\b(find|search|browse|for|me|a|an|template|templates)\b", " ", normalized
|
| 179 |
+
)
|
| 180 |
+
query = " ".join(query.split()) or normalized
|
| 181 |
+
category = next(
|
| 182 |
+
(
|
| 183 |
+
item
|
| 184 |
+
for item in (
|
| 185 |
+
"business",
|
| 186 |
+
"marketing",
|
| 187 |
+
"education",
|
| 188 |
+
"podcast",
|
| 189 |
+
"gaming",
|
| 190 |
+
"news",
|
| 191 |
+
"social",
|
| 192 |
+
"youtube",
|
| 193 |
+
"tiktok",
|
| 194 |
+
"instagram",
|
| 195 |
+
"product",
|
| 196 |
+
"personal",
|
| 197 |
+
)
|
| 198 |
+
if item in lowered
|
| 199 |
+
),
|
| 200 |
+
None,
|
| 201 |
+
)
|
| 202 |
+
action = TemplateSearchAction(
|
| 203 |
+
id=action_id,
|
| 204 |
+
type="template.search",
|
| 205 |
+
arguments=TemplateSearchArguments(query=query, category=category),
|
| 206 |
+
reason="Search the authoritative visible template catalog.",
|
| 207 |
+
requires_confirmation=False,
|
| 208 |
+
destructive=False,
|
| 209 |
+
external_side_effect=False,
|
| 210 |
+
required_permission="templates:read",
|
| 211 |
+
required_capability="template.search",
|
| 212 |
+
)
|
| 213 |
+
return self._plan(
|
| 214 |
+
"Search templates",
|
| 215 |
+
"Search the versioned marketplace catalog using the current workspace context.",
|
| 216 |
+
[action],
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
if "share" in lowered and "project" in lowered and re.search(r"\b(with)\b", lowered):
|
| 220 |
+
# Example parsing for demo purposes
|
| 221 |
+
project_id = "..." # simplified
|
| 222 |
+
user_id = "..." # simplified
|
| 223 |
+
role = "viewer"
|
| 224 |
+
action = ShareProjectAction(
|
| 225 |
+
id=action_id,
|
| 226 |
+
type="project.share",
|
| 227 |
+
arguments=ShareProjectArguments(project_id=project_id, user_id=user_id, role=role),
|
| 228 |
+
reason="Share project with user.",
|
| 229 |
+
requires_confirmation=True,
|
| 230 |
+
destructive=False,
|
| 231 |
+
external_side_effect=True,
|
| 232 |
+
required_permission="projects:share",
|
| 233 |
+
required_capability="project.share",
|
| 234 |
+
)
|
| 235 |
+
return self._plan("Share project", "Share project with another user.", [action])
|
| 236 |
+
|
| 237 |
+
if template_id and "template" in lowered and re.search(r"\b(open|show|inspect)\b", lowered):
|
| 238 |
+
action = TemplateGetAction(
|
| 239 |
+
id=action_id,
|
| 240 |
+
type="template.get",
|
| 241 |
+
arguments=TemplateGetArguments(template_id=template_id),
|
| 242 |
+
reason="Inspect the selected authoritative template.",
|
| 243 |
+
requires_confirmation=False,
|
| 244 |
+
destructive=False,
|
| 245 |
+
external_side_effect=False,
|
| 246 |
+
required_permission="templates:read",
|
| 247 |
+
required_capability="template.get",
|
| 248 |
+
)
|
| 249 |
+
return self._plan("Open template", "Open the selected template.", [action])
|
| 250 |
+
|
| 251 |
+
if template_id and "template" in lowered and re.search(r"\b(use|apply)\b", lowered):
|
| 252 |
+
if project_id is None:
|
| 253 |
+
return self._blocked(
|
| 254 |
+
normalized,
|
| 255 |
+
"Applying a template requires a selected project.",
|
| 256 |
+
missing=["project"],
|
| 257 |
+
)
|
| 258 |
+
action = TemplateApplyAction(
|
| 259 |
+
id=action_id,
|
| 260 |
+
type="template.apply",
|
| 261 |
+
arguments=TemplateApplyArguments(
|
| 262 |
+
template_id=template_id,
|
| 263 |
+
project_id=project_id,
|
| 264 |
+
slot_bindings={},
|
| 265 |
+
),
|
| 266 |
+
reason="Apply the selected versioned template to the current project.",
|
| 267 |
+
requires_confirmation=True,
|
| 268 |
+
destructive=True,
|
| 269 |
+
external_side_effect=False,
|
| 270 |
+
required_permission="templates:apply",
|
| 271 |
+
required_capability="template.apply",
|
| 272 |
+
)
|
| 273 |
+
return self._plan(
|
| 274 |
+
"Apply template",
|
| 275 |
+
"Validate requirements and replace the current authoritative editor state after confirmation.",
|
| 276 |
+
[action],
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if template_id and "template" in lowered and "create project" in lowered:
|
| 280 |
+
name_match = re.search(r'\bnamed\s+["“]?([^"”]+?)["”]?\s*$', normalized, re.IGNORECASE)
|
| 281 |
+
if name_match is None:
|
| 282 |
+
return self._blocked(
|
| 283 |
+
normalized,
|
| 284 |
+
"Creating a project from a template requires an explicit project name.",
|
| 285 |
+
missing=["project name"],
|
| 286 |
+
)
|
| 287 |
+
action = TemplateCreateProjectAction(
|
| 288 |
+
id=action_id,
|
| 289 |
+
type="template.create_project",
|
| 290 |
+
arguments=TemplateCreateProjectArguments(
|
| 291 |
+
template_id=template_id,
|
| 292 |
+
project_name=name_match.group(1).strip(),
|
| 293 |
+
slot_bindings={},
|
| 294 |
+
),
|
| 295 |
+
reason="Create an editable project from the selected template.",
|
| 296 |
+
requires_confirmation=True,
|
| 297 |
+
destructive=False,
|
| 298 |
+
external_side_effect=False,
|
| 299 |
+
required_permission="templates:apply",
|
| 300 |
+
required_capability="template.create_project",
|
| 301 |
+
)
|
| 302 |
+
return self._plan(
|
| 303 |
+
"Create project from template",
|
| 304 |
+
"Validate requirements and create a new editable project after confirmation.",
|
| 305 |
+
[action],
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
if re.search(r"\b(open|show)\b.*\bproject\b", lowered):
|
| 309 |
+
if project_id is None:
|
| 310 |
+
return self._blocked(normalized, "Select a project first.", missing=["project"])
|
| 311 |
+
action = ProjectOpenAction(
|
| 312 |
+
id=action_id,
|
| 313 |
+
type="project.open",
|
| 314 |
+
arguments=ProjectOpenArguments(project_id=project_id),
|
| 315 |
+
reason="Open the selected project.",
|
| 316 |
+
requires_confirmation=False,
|
| 317 |
+
destructive=False,
|
| 318 |
+
external_side_effect=False,
|
| 319 |
+
required_permission="projects:read",
|
| 320 |
+
required_capability="project.open",
|
| 321 |
+
)
|
| 322 |
+
return self._plan("Open project", "Open the selected project.", [action])
|
| 323 |
+
|
| 324 |
+
if re.search(r"\b(open|select|show)\b.*\basset\b", lowered):
|
| 325 |
+
if selected_asset is None:
|
| 326 |
+
return self._blocked(normalized, "Select an asset first.", missing=["asset"])
|
| 327 |
+
action = AssetSelectAction(
|
| 328 |
+
id=action_id,
|
| 329 |
+
type="asset.select",
|
| 330 |
+
arguments=AssetSelectArguments(asset_id=selected_asset, project_id=project_id),
|
| 331 |
+
reason="Open the selected canonical asset.",
|
| 332 |
+
requires_confirmation=False,
|
| 333 |
+
destructive=False,
|
| 334 |
+
external_side_effect=False,
|
| 335 |
+
required_permission="assets:read",
|
| 336 |
+
required_capability="asset.select",
|
| 337 |
+
)
|
| 338 |
+
return self._plan("Open asset", "Open the selected asset.", [action])
|
| 339 |
+
|
| 340 |
+
if "generate" in lowered and ("video" in lowered or "animate" in lowered):
|
| 341 |
+
if "ai.generate_video" not in context.available_capabilities:
|
| 342 |
+
return self._blocked(
|
| 343 |
+
normalized,
|
| 344 |
+
"Video generation is not currently available.",
|
| 345 |
+
unsupported=["ai.generate_video"],
|
| 346 |
+
)
|
| 347 |
+
if selected_asset is None:
|
| 348 |
+
return self._blocked(
|
| 349 |
+
normalized,
|
| 350 |
+
"Video generation requires a selected source image.",
|
| 351 |
+
missing=["source image asset"],
|
| 352 |
+
)
|
| 353 |
+
prompt = self._generation_prompt(normalized, "video")
|
| 354 |
+
action = AiGenerateVideoAction(
|
| 355 |
+
id=action_id,
|
| 356 |
+
type="ai.generate_video",
|
| 357 |
+
arguments=AiGenerateVideoArguments(
|
| 358 |
+
prompt=prompt,
|
| 359 |
+
project_id=project_id,
|
| 360 |
+
source_asset_id=selected_asset,
|
| 361 |
+
),
|
| 362 |
+
reason="Submit a real image-to-video generation job.",
|
| 363 |
+
requires_confirmation=True,
|
| 364 |
+
destructive=False,
|
| 365 |
+
external_side_effect=False,
|
| 366 |
+
required_permission="ai:generate",
|
| 367 |
+
required_capability="ai.generate_video",
|
| 368 |
+
)
|
| 369 |
+
return self._plan(
|
| 370 |
+
"Generate video",
|
| 371 |
+
"Generate a video from the selected image using AI Studio.",
|
| 372 |
+
[action],
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
if "generate" in lowered and any(
|
| 376 |
+
word in lowered for word in ("image", "thumbnail", "artwork")
|
| 377 |
+
):
|
| 378 |
+
if "ai.generate_image" not in context.available_capabilities:
|
| 379 |
+
return self._blocked(
|
| 380 |
+
normalized,
|
| 381 |
+
"Image generation is not currently available.",
|
| 382 |
+
unsupported=["ai.generate_image"],
|
| 383 |
+
)
|
| 384 |
+
prompt = self._generation_prompt(normalized, "image")
|
| 385 |
+
action = AiGenerateImageAction(
|
| 386 |
+
id=action_id,
|
| 387 |
+
type="ai.generate_image",
|
| 388 |
+
arguments=AiGenerateImageArguments(
|
| 389 |
+
prompt=prompt,
|
| 390 |
+
project_id=project_id,
|
| 391 |
+
source_asset_id=selected_asset,
|
| 392 |
+
),
|
| 393 |
+
reason="Submit a real image generation job.",
|
| 394 |
+
requires_confirmation=True,
|
| 395 |
+
destructive=False,
|
| 396 |
+
external_side_effect=False,
|
| 397 |
+
required_permission="ai:generate",
|
| 398 |
+
required_capability="ai.generate_image",
|
| 399 |
+
)
|
| 400 |
+
return self._plan(
|
| 401 |
+
"Generate image",
|
| 402 |
+
"Generate an image using the currently available AI Studio model.",
|
| 403 |
+
[action],
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
if re.search(r"\b(render|export)\b", lowered):
|
| 407 |
+
if project_id is None or revision is None:
|
| 408 |
+
return self._blocked(
|
| 409 |
+
normalized,
|
| 410 |
+
"Rendering requires a project with saved editor state.",
|
| 411 |
+
missing=["saved editor state"],
|
| 412 |
+
)
|
| 413 |
+
action = EditorRenderAction(
|
| 414 |
+
id=action_id,
|
| 415 |
+
type="editor.render",
|
| 416 |
+
arguments=EditorRenderArguments(project_id=project_id, expected_revision=revision),
|
| 417 |
+
reason="Submit the current authoritative editor revision for rendering.",
|
| 418 |
+
requires_confirmation=True,
|
| 419 |
+
destructive=False,
|
| 420 |
+
external_side_effect=False,
|
| 421 |
+
required_permission="projects:update",
|
| 422 |
+
required_capability="editor.render",
|
| 423 |
+
)
|
| 424 |
+
return self._plan(
|
| 425 |
+
"Render project",
|
| 426 |
+
"Validate and submit the current editor revision to the existing render pipeline.",
|
| 427 |
+
[action],
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
split_match = re.search(
|
| 431 |
+
r"\bsplit\b.*?\b(?:at\s+)?(\d+(?:\.\d+)?)\s*(seconds?|secs?|s)\b",
|
| 432 |
+
lowered,
|
| 433 |
+
)
|
| 434 |
+
if split_match:
|
| 435 |
+
missing = self._editor_missing(project_id, revision, selected_clip)
|
| 436 |
+
if missing:
|
| 437 |
+
return self._blocked(
|
| 438 |
+
normalized, "Select a saved timeline clip first.", missing=missing
|
| 439 |
+
)
|
| 440 |
+
action = EditorSplitClipAction(
|
| 441 |
+
id=action_id,
|
| 442 |
+
type="editor.split_clip",
|
| 443 |
+
arguments=EditorSplitClipArguments(
|
| 444 |
+
project_id=project_id,
|
| 445 |
+
clip_id=selected_clip,
|
| 446 |
+
at_ms=round(float(split_match.group(1)) * 1_000),
|
| 447 |
+
expected_revision=revision,
|
| 448 |
+
),
|
| 449 |
+
reason="Split the selected clip at the requested timeline time.",
|
| 450 |
+
requires_confirmation=False,
|
| 451 |
+
destructive=False,
|
| 452 |
+
external_side_effect=False,
|
| 453 |
+
required_permission="projects:update",
|
| 454 |
+
required_capability="editor.split_clip",
|
| 455 |
+
)
|
| 456 |
+
return self._plan(
|
| 457 |
+
"Split selected clip",
|
| 458 |
+
"Save a revision-safe split through the authoritative editor service.",
|
| 459 |
+
[action],
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
if re.search(r"\b(delete|remove)\b.*\b(selected\s+)?clip\b", lowered):
|
| 463 |
+
missing = self._editor_missing(project_id, revision, selected_clip)
|
| 464 |
+
if missing:
|
| 465 |
+
return self._blocked(
|
| 466 |
+
normalized, "Select a saved timeline clip first.", missing=missing
|
| 467 |
+
)
|
| 468 |
+
action = EditorDeleteClipAction(
|
| 469 |
+
id=action_id,
|
| 470 |
+
type="editor.delete_clip",
|
| 471 |
+
arguments=EditorDeleteClipArguments(
|
| 472 |
+
project_id=project_id,
|
| 473 |
+
clip_id=selected_clip,
|
| 474 |
+
expected_revision=revision,
|
| 475 |
+
),
|
| 476 |
+
reason="Remove the selected clip from the authoritative timeline.",
|
| 477 |
+
requires_confirmation=True,
|
| 478 |
+
destructive=True,
|
| 479 |
+
external_side_effect=False,
|
| 480 |
+
required_permission="projects:update",
|
| 481 |
+
required_capability="editor.delete_clip",
|
| 482 |
+
)
|
| 483 |
+
return self._plan(
|
| 484 |
+
"Delete selected clip",
|
| 485 |
+
"Delete the selected clip after explicit confirmation.",
|
| 486 |
+
[action],
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
duration_match = re.search(
|
| 490 |
+
r"\b(?:last|duration|make)\b.*?(\d+(?:\.\d+)?)\s*(seconds?|secs?|s)\b",
|
| 491 |
+
lowered,
|
| 492 |
+
)
|
| 493 |
+
if duration_match and ("clip" in lowered or "image" in lowered):
|
| 494 |
+
missing = self._editor_missing(project_id, revision, selected_clip)
|
| 495 |
+
if missing:
|
| 496 |
+
return self._blocked(
|
| 497 |
+
normalized, "Select a saved timeline clip first.", missing=missing
|
| 498 |
+
)
|
| 499 |
+
action = EditorSetDurationAction(
|
| 500 |
+
id=action_id,
|
| 501 |
+
type="editor.set_duration",
|
| 502 |
+
arguments=EditorSetDurationArguments(
|
| 503 |
+
project_id=project_id,
|
| 504 |
+
clip_id=selected_clip,
|
| 505 |
+
duration_ms=round(float(duration_match.group(1)) * 1_000),
|
| 506 |
+
expected_revision=revision,
|
| 507 |
+
),
|
| 508 |
+
reason="Set the selected clip duration.",
|
| 509 |
+
requires_confirmation=False,
|
| 510 |
+
destructive=False,
|
| 511 |
+
external_side_effect=False,
|
| 512 |
+
required_permission="projects:update",
|
| 513 |
+
required_capability="editor.set_duration",
|
| 514 |
+
)
|
| 515 |
+
return self._plan(
|
| 516 |
+
"Update clip duration",
|
| 517 |
+
"Save the requested duration through the authoritative editor service.",
|
| 518 |
+
[action],
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
if re.search(r"\badd\b.*\b(asset|image|video|audio)\b.*\b(timeline|editor)\b", lowered):
|
| 522 |
+
if project_id is None or revision is None or selected_asset is None:
|
| 523 |
+
return self._blocked(
|
| 524 |
+
normalized,
|
| 525 |
+
"Adding media requires a project, saved editor state, and selected asset.",
|
| 526 |
+
missing=["project", "saved editor state", "asset"],
|
| 527 |
+
)
|
| 528 |
+
action = EditorAddClipAction(
|
| 529 |
+
id=action_id,
|
| 530 |
+
type="editor.add_clip",
|
| 531 |
+
arguments=EditorAddClipArguments(
|
| 532 |
+
project_id=project_id,
|
| 533 |
+
asset_id=selected_asset,
|
| 534 |
+
expected_revision=revision,
|
| 535 |
+
),
|
| 536 |
+
reason="Add the selected canonical asset to the timeline.",
|
| 537 |
+
requires_confirmation=False,
|
| 538 |
+
destructive=False,
|
| 539 |
+
external_side_effect=False,
|
| 540 |
+
required_permission="projects:update",
|
| 541 |
+
required_capability="editor.add_clip",
|
| 542 |
+
)
|
| 543 |
+
return self._plan(
|
| 544 |
+
"Add asset to timeline",
|
| 545 |
+
"Insert the selected project asset through the authoritative editor service.",
|
| 546 |
+
[action],
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
return self._blocked(
|
| 550 |
+
normalized,
|
| 551 |
+
"This request does not map to a currently registered Copilot action.",
|
| 552 |
+
unsupported=["natural-language intent"],
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
@staticmethod
|
| 556 |
+
def _editor_missing(project_id, revision, selected_clip) -> list[str]:
|
| 557 |
+
missing = []
|
| 558 |
+
if project_id is None:
|
| 559 |
+
missing.append("project")
|
| 560 |
+
if revision is None:
|
| 561 |
+
missing.append("saved editor state")
|
| 562 |
+
if selected_clip is None:
|
| 563 |
+
missing.append("selected clip")
|
| 564 |
+
return missing
|
| 565 |
+
|
| 566 |
+
@staticmethod
|
| 567 |
+
def _generation_prompt(request: str, media_word: str) -> str:
|
| 568 |
+
stripped = re.sub(
|
| 569 |
+
rf"(?i)^\s*(please\s+)?generate\s+(an?\s+)?{media_word}\s*(of|for|with|:)?\s*",
|
| 570 |
+
"",
|
| 571 |
+
request,
|
| 572 |
+
).strip()
|
| 573 |
+
return stripped or request
|
| 574 |
+
|
| 575 |
+
@staticmethod
|
| 576 |
+
def _template_id(request: str) -> str | None:
|
| 577 |
+
match = re.search(
|
| 578 |
+
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b",
|
| 579 |
+
request,
|
| 580 |
+
re.IGNORECASE,
|
| 581 |
+
)
|
| 582 |
+
return match.group(0) if match else None
|
| 583 |
+
|
| 584 |
+
@staticmethod
|
| 585 |
+
def _requested_unavailable_capability(request: str, context: CopilotContext) -> str | None:
|
| 586 |
+
requested = {
|
| 587 |
+
"transcrib": "ai.transcribe",
|
| 588 |
+
"upscal": "ai.upscale",
|
| 589 |
+
"remove background": "ai.remove_background",
|
| 590 |
+
"voice": "ai.generate_voice",
|
| 591 |
+
"music": "ai.generate_music",
|
| 592 |
+
"caption": "ai.transcribe",
|
| 593 |
+
"tiktok": "ai.transcribe",
|
| 594 |
+
"highlight": "ai.analyze",
|
| 595 |
+
}
|
| 596 |
+
for fragment, capability in requested.items():
|
| 597 |
+
if fragment in request and capability not in context.available_capabilities:
|
| 598 |
+
return capability
|
| 599 |
+
return None
|
| 600 |
+
|
| 601 |
+
@staticmethod
|
| 602 |
+
def _plan(intent: str, explanation: str, actions: list) -> CopilotPlan:
|
| 603 |
+
return CopilotPlan(
|
| 604 |
+
intent=intent,
|
| 605 |
+
explanation=explanation,
|
| 606 |
+
actions=actions,
|
| 607 |
+
executable=True,
|
| 608 |
+
requires_confirmation=any(action.requires_confirmation for action in actions),
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
@staticmethod
|
| 612 |
+
def _blocked(
|
| 613 |
+
intent: str,
|
| 614 |
+
explanation: str,
|
| 615 |
+
*,
|
| 616 |
+
missing: list[str] | None = None,
|
| 617 |
+
unsupported: list[str] | None = None,
|
| 618 |
+
) -> CopilotPlan:
|
| 619 |
+
return CopilotPlan(
|
| 620 |
+
intent=intent[:200],
|
| 621 |
+
explanation=explanation,
|
| 622 |
+
actions=[],
|
| 623 |
+
missing_information=missing or [],
|
| 624 |
+
unsupported_capabilities=unsupported or [],
|
| 625 |
+
executable=False,
|
| 626 |
+
requires_confirmation=False,
|
| 627 |
+
)
|
app/copilot/repository.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from sqlalchemy.exc import IntegrityError
|
| 7 |
+
|
| 8 |
+
from app.copilot.errors import CopilotRunConflictError, CopilotRunNotFoundError
|
| 9 |
+
from app.copilot.models import CopilotRunRecord
|
| 10 |
+
from app.security.database import SecurityDatabase
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CopilotRepository:
|
| 14 |
+
def __init__(self, database: SecurityDatabase) -> None:
|
| 15 |
+
self.database = database
|
| 16 |
+
|
| 17 |
+
async def create(self, record: CopilotRunRecord) -> tuple[CopilotRunRecord, bool]:
|
| 18 |
+
try:
|
| 19 |
+
async with self.database.tenant_session(
|
| 20 |
+
workspace_id=record.workspace_id, user_id=record.user_id
|
| 21 |
+
) as session:
|
| 22 |
+
session.add(record)
|
| 23 |
+
await session.commit()
|
| 24 |
+
await session.refresh(record)
|
| 25 |
+
return record, True
|
| 26 |
+
except IntegrityError:
|
| 27 |
+
existing = await self.get_by_idempotency(
|
| 28 |
+
record.workspace_id, record.user_id, record.idempotency_key
|
| 29 |
+
)
|
| 30 |
+
if existing is None:
|
| 31 |
+
raise
|
| 32 |
+
if existing.request_fingerprint != record.request_fingerprint:
|
| 33 |
+
raise CopilotRunConflictError(
|
| 34 |
+
"Idempotency-Key is already associated with another Copilot request."
|
| 35 |
+
)
|
| 36 |
+
return existing, False
|
| 37 |
+
|
| 38 |
+
async def get_by_idempotency(
|
| 39 |
+
self, workspace_id: str, user_id: str, key: str
|
| 40 |
+
) -> CopilotRunRecord | None:
|
| 41 |
+
async with self.database.tenant_session(
|
| 42 |
+
workspace_id=workspace_id, user_id=user_id
|
| 43 |
+
) as session:
|
| 44 |
+
return await session.scalar(
|
| 45 |
+
select(CopilotRunRecord).where(
|
| 46 |
+
CopilotRunRecord.workspace_id == workspace_id,
|
| 47 |
+
CopilotRunRecord.idempotency_key == key,
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
async def get(
|
| 52 |
+
self, workspace_id: str, user_id: str, run_id: str, *, lock: bool = False
|
| 53 |
+
) -> CopilotRunRecord:
|
| 54 |
+
async with self.database.tenant_session(
|
| 55 |
+
workspace_id=workspace_id, user_id=user_id
|
| 56 |
+
) as session:
|
| 57 |
+
statement = select(CopilotRunRecord).where(
|
| 58 |
+
CopilotRunRecord.id == run_id,
|
| 59 |
+
CopilotRunRecord.workspace_id == workspace_id,
|
| 60 |
+
)
|
| 61 |
+
if lock:
|
| 62 |
+
statement = statement.with_for_update()
|
| 63 |
+
record = await session.scalar(statement)
|
| 64 |
+
if record is None:
|
| 65 |
+
raise CopilotRunNotFoundError("Copilot run was not found in this workspace.")
|
| 66 |
+
return record
|
| 67 |
+
|
| 68 |
+
async def list(
|
| 69 |
+
self, workspace_id: str, user_id: str, *, offset: int, limit: int
|
| 70 |
+
) -> list[CopilotRunRecord]:
|
| 71 |
+
async with self.database.tenant_session(
|
| 72 |
+
workspace_id=workspace_id, user_id=user_id
|
| 73 |
+
) as session:
|
| 74 |
+
return list(
|
| 75 |
+
(
|
| 76 |
+
await session.scalars(
|
| 77 |
+
select(CopilotRunRecord)
|
| 78 |
+
.where(CopilotRunRecord.workspace_id == workspace_id)
|
| 79 |
+
.order_by(CopilotRunRecord.created_at.desc())
|
| 80 |
+
.offset(offset)
|
| 81 |
+
.limit(limit)
|
| 82 |
+
)
|
| 83 |
+
).all()
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
async def claim_execution(
|
| 87 |
+
self,
|
| 88 |
+
workspace_id: str,
|
| 89 |
+
user_id: str,
|
| 90 |
+
run_id: str,
|
| 91 |
+
*,
|
| 92 |
+
confirmed: bool,
|
| 93 |
+
) -> CopilotRunRecord:
|
| 94 |
+
async with self.database.tenant_session(
|
| 95 |
+
workspace_id=workspace_id, user_id=user_id
|
| 96 |
+
) as session:
|
| 97 |
+
record = await session.scalar(
|
| 98 |
+
select(CopilotRunRecord)
|
| 99 |
+
.where(
|
| 100 |
+
CopilotRunRecord.id == run_id,
|
| 101 |
+
CopilotRunRecord.workspace_id == workspace_id,
|
| 102 |
+
)
|
| 103 |
+
.with_for_update()
|
| 104 |
+
)
|
| 105 |
+
if record is None:
|
| 106 |
+
raise CopilotRunNotFoundError("Copilot run was not found in this workspace.")
|
| 107 |
+
if record.status != "plan_ready":
|
| 108 |
+
raise CopilotRunConflictError("Only a plan-ready Copilot run can be executed.")
|
| 109 |
+
now = datetime.now(timezone.utc)
|
| 110 |
+
record.status = "executing"
|
| 111 |
+
record.updated_at = now
|
| 112 |
+
if confirmed and record.confirmed_at is None:
|
| 113 |
+
record.confirmed_at = now
|
| 114 |
+
await session.commit()
|
| 115 |
+
await session.refresh(record)
|
| 116 |
+
return record
|
| 117 |
+
|
| 118 |
+
async def cancel_before_execution(
|
| 119 |
+
self,
|
| 120 |
+
workspace_id: str,
|
| 121 |
+
user_id: str,
|
| 122 |
+
run_id: str,
|
| 123 |
+
) -> tuple[CopilotRunRecord, bool]:
|
| 124 |
+
async with self.database.tenant_session(
|
| 125 |
+
workspace_id=workspace_id, user_id=user_id
|
| 126 |
+
) as session:
|
| 127 |
+
record = await session.scalar(
|
| 128 |
+
select(CopilotRunRecord)
|
| 129 |
+
.where(
|
| 130 |
+
CopilotRunRecord.id == run_id,
|
| 131 |
+
CopilotRunRecord.workspace_id == workspace_id,
|
| 132 |
+
)
|
| 133 |
+
.with_for_update()
|
| 134 |
+
)
|
| 135 |
+
if record is None:
|
| 136 |
+
raise CopilotRunNotFoundError("Copilot run was not found in this workspace.")
|
| 137 |
+
if record.status in {"completed", "partial", "failed", "cancelled"}:
|
| 138 |
+
return record, False
|
| 139 |
+
if record.status == "executing":
|
| 140 |
+
raise CopilotRunConflictError(
|
| 141 |
+
"This action batch is already executing; cancel its durable child job directly."
|
| 142 |
+
)
|
| 143 |
+
now = datetime.now(timezone.utc)
|
| 144 |
+
record.status = "cancelled"
|
| 145 |
+
record.current_action_id = None
|
| 146 |
+
record.summary = "Copilot run cancelled before execution."
|
| 147 |
+
record.updated_at = now
|
| 148 |
+
record.completed_at = now
|
| 149 |
+
await session.commit()
|
| 150 |
+
await session.refresh(record)
|
| 151 |
+
return record, True
|
| 152 |
+
|
| 153 |
+
async def update(
|
| 154 |
+
self,
|
| 155 |
+
workspace_id: str,
|
| 156 |
+
user_id: str,
|
| 157 |
+
run_id: str,
|
| 158 |
+
*,
|
| 159 |
+
status: str,
|
| 160 |
+
current_action_id: str | None = None,
|
| 161 |
+
results: list[dict[str, object]] | None = None,
|
| 162 |
+
summary: str | None = None,
|
| 163 |
+
error_code: str | None = None,
|
| 164 |
+
error_message: str | None = None,
|
| 165 |
+
confirmed: bool = False,
|
| 166 |
+
terminal: bool = False,
|
| 167 |
+
) -> CopilotRunRecord:
|
| 168 |
+
async with self.database.tenant_session(
|
| 169 |
+
workspace_id=workspace_id, user_id=user_id
|
| 170 |
+
) as session:
|
| 171 |
+
record = await session.scalar(
|
| 172 |
+
select(CopilotRunRecord)
|
| 173 |
+
.where(
|
| 174 |
+
CopilotRunRecord.id == run_id,
|
| 175 |
+
CopilotRunRecord.workspace_id == workspace_id,
|
| 176 |
+
)
|
| 177 |
+
.with_for_update()
|
| 178 |
+
)
|
| 179 |
+
if record is None:
|
| 180 |
+
raise CopilotRunNotFoundError("Copilot run was not found in this workspace.")
|
| 181 |
+
now = datetime.now(timezone.utc)
|
| 182 |
+
record.status = status
|
| 183 |
+
record.current_action_id = current_action_id
|
| 184 |
+
if results is not None:
|
| 185 |
+
record.results_json = results
|
| 186 |
+
record.summary = summary
|
| 187 |
+
record.error_code = error_code
|
| 188 |
+
record.error_message = error_message
|
| 189 |
+
if confirmed and record.confirmed_at is None:
|
| 190 |
+
record.confirmed_at = now
|
| 191 |
+
record.updated_at = now
|
| 192 |
+
if terminal:
|
| 193 |
+
record.completed_at = now
|
| 194 |
+
await session.commit()
|
| 195 |
+
await session.refresh(record)
|
| 196 |
+
return record
|
app/copilot/schemas.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Annotated, Literal
|
| 5 |
+
from uuid import UUID
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 8 |
+
|
| 9 |
+
from app.social.schemas.posts import SocialPostCreate
|
| 10 |
+
from app.social.schemas.scheduling import SocialScheduleCreate
|
| 11 |
+
from app.templates.marketplace_schemas import SlotBinding
|
| 12 |
+
|
| 13 |
+
CopilotRunStatus = Literal[
|
| 14 |
+
"plan_ready",
|
| 15 |
+
"blocked",
|
| 16 |
+
"executing",
|
| 17 |
+
"completed",
|
| 18 |
+
"partial",
|
| 19 |
+
"failed",
|
| 20 |
+
"cancelled",
|
| 21 |
+
]
|
| 22 |
+
CopilotActionStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class CopilotEditorSummary(BaseModel):
|
| 26 |
+
model_config = ConfigDict(extra="forbid")
|
| 27 |
+
|
| 28 |
+
revision: int = Field(ge=1)
|
| 29 |
+
duration_ms: int = Field(ge=0)
|
| 30 |
+
track_count: int = Field(ge=0, le=32)
|
| 31 |
+
clip_count: int = Field(ge=0, le=500)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class CopilotContextInput(BaseModel):
|
| 35 |
+
model_config = ConfigDict(extra="forbid")
|
| 36 |
+
|
| 37 |
+
project_id: UUID | None = None
|
| 38 |
+
selected_asset_ids: list[UUID] = Field(default_factory=list, max_length=20)
|
| 39 |
+
selected_clip_ids: list[str] = Field(default_factory=list, max_length=20)
|
| 40 |
+
active_tool: str | None = Field(default=None, max_length=100)
|
| 41 |
+
editor_summary: CopilotEditorSummary | None = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class CopilotContext(CopilotContextInput):
|
| 45 |
+
workspace_id: str
|
| 46 |
+
available_capabilities: list[str] = Field(default_factory=list, max_length=100)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ProjectOpenArguments(BaseModel):
|
| 50 |
+
model_config = ConfigDict(extra="forbid")
|
| 51 |
+
project_id: UUID
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AssetSelectArguments(BaseModel):
|
| 55 |
+
model_config = ConfigDict(extra="forbid")
|
| 56 |
+
asset_id: UUID
|
| 57 |
+
project_id: UUID | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class AiGenerateImageArguments(BaseModel):
|
| 61 |
+
model_config = ConfigDict(extra="forbid")
|
| 62 |
+
prompt: str = Field(min_length=1, max_length=4_000)
|
| 63 |
+
project_id: UUID | None = None
|
| 64 |
+
source_asset_id: UUID | None = None
|
| 65 |
+
model: str | None = Field(default=None, max_length=255)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class AiGenerateVideoArguments(BaseModel):
|
| 69 |
+
model_config = ConfigDict(extra="forbid")
|
| 70 |
+
prompt: str = Field(min_length=1, max_length=4_000)
|
| 71 |
+
project_id: UUID | None = None
|
| 72 |
+
source_asset_id: UUID
|
| 73 |
+
model: str | None = Field(default=None, max_length=255)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class EditorSplitClipArguments(BaseModel):
|
| 77 |
+
model_config = ConfigDict(extra="forbid")
|
| 78 |
+
project_id: UUID
|
| 79 |
+
clip_id: str = Field(min_length=1, max_length=128)
|
| 80 |
+
at_ms: int = Field(gt=0)
|
| 81 |
+
expected_revision: int = Field(ge=1)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class EditorDeleteClipArguments(BaseModel):
|
| 85 |
+
model_config = ConfigDict(extra="forbid")
|
| 86 |
+
project_id: UUID
|
| 87 |
+
clip_id: str = Field(min_length=1, max_length=128)
|
| 88 |
+
expected_revision: int = Field(ge=1)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class EditorSetDurationArguments(BaseModel):
|
| 92 |
+
model_config = ConfigDict(extra="forbid")
|
| 93 |
+
project_id: UUID
|
| 94 |
+
clip_id: str = Field(min_length=1, max_length=128)
|
| 95 |
+
duration_ms: int = Field(gt=0)
|
| 96 |
+
expected_revision: int = Field(ge=1)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class EditorAddClipArguments(BaseModel):
|
| 100 |
+
model_config = ConfigDict(extra="forbid")
|
| 101 |
+
project_id: UUID
|
| 102 |
+
asset_id: UUID
|
| 103 |
+
expected_revision: int = Field(ge=1)
|
| 104 |
+
duration_ms: int = Field(default=5_000, gt=0)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class EditorRenderArguments(BaseModel):
|
| 108 |
+
model_config = ConfigDict(extra="forbid")
|
| 109 |
+
project_id: UUID
|
| 110 |
+
expected_revision: int = Field(ge=1)
|
| 111 |
+
|
| 112 |
+
class TemplateSearchArguments(BaseModel):
|
| 113 |
+
model_config = ConfigDict(extra="forbid")
|
| 114 |
+
query: str = Field(min_length=1, max_length=200)
|
| 115 |
+
category: str | None = Field(default=None, max_length=50)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TemplateGetArguments(BaseModel):
|
| 119 |
+
model_config = ConfigDict(extra="forbid")
|
| 120 |
+
template_id: UUID
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class TemplateApplyArguments(BaseModel):
|
| 124 |
+
model_config = ConfigDict(extra="forbid")
|
| 125 |
+
template_id: UUID
|
| 126 |
+
project_id: UUID
|
| 127 |
+
template_version_id: UUID | None = None
|
| 128 |
+
slot_bindings: dict[str, SlotBinding] = Field(default_factory=dict, max_length=100)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class TemplateCreateProjectArguments(BaseModel):
|
| 132 |
+
model_config = ConfigDict(extra="forbid")
|
| 133 |
+
template_id: UUID
|
| 134 |
+
project_name: str = Field(min_length=1, max_length=200)
|
| 135 |
+
template_version_id: UUID | None = None
|
| 136 |
+
slot_bindings: dict[str, SlotBinding] = Field(default_factory=dict, max_length=100)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class PublishingValidateArguments(BaseModel):
|
| 140 |
+
model_config = ConfigDict(extra="forbid")
|
| 141 |
+
post_id: UUID
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class PublishingCreatePostArguments(BaseModel):
|
| 145 |
+
model_config = ConfigDict(extra="forbid")
|
| 146 |
+
post: SocialPostCreate
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class PublishingScheduleArguments(BaseModel):
|
| 150 |
+
model_config = ConfigDict(extra="forbid")
|
| 151 |
+
post_id: UUID
|
| 152 |
+
schedule: SocialScheduleCreate
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class PublishingPublishArguments(BaseModel):
|
| 156 |
+
model_config = ConfigDict(extra="forbid")
|
| 157 |
+
post_id: UUID
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class PublishingCancelArguments(BaseModel):
|
| 161 |
+
model_config = ConfigDict(extra="forbid")
|
| 162 |
+
post_id: UUID
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class AnalyticsOverviewArguments(BaseModel):
|
| 166 |
+
model_config = ConfigDict(extra="forbid")
|
| 167 |
+
project_id: UUID | None = None
|
| 168 |
+
provider: str | None = Field(default=None, max_length=32)
|
| 169 |
+
metric: Literal["views", "impressions", "likes", "comments", "shares", "engagement_rate"] = (
|
| 170 |
+
"views"
|
| 171 |
+
)
|
| 172 |
+
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class AnalyticsSyncArguments(BaseModel):
|
| 176 |
+
model_config = ConfigDict(extra="forbid")
|
| 177 |
+
project_id: UUID | None = None
|
| 178 |
+
provider: str | None = Field(default=None, max_length=32)
|
| 179 |
+
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class _ActionBase(BaseModel):
|
| 183 |
+
model_config = ConfigDict(extra="forbid")
|
| 184 |
+
|
| 185 |
+
id: str = Field(min_length=1, max_length=64)
|
| 186 |
+
reason: str = Field(min_length=1, max_length=500)
|
| 187 |
+
requires_confirmation: bool
|
| 188 |
+
destructive: bool
|
| 189 |
+
external_side_effect: bool
|
| 190 |
+
required_permission: str = Field(min_length=1, max_length=100)
|
| 191 |
+
required_capability: str = Field(min_length=1, max_length=100)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class ProjectOpenAction(_ActionBase):
|
| 195 |
+
type: Literal["project.open"]
|
| 196 |
+
arguments: ProjectOpenArguments
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class AssetSelectAction(_ActionBase):
|
| 200 |
+
type: Literal["asset.select"]
|
| 201 |
+
arguments: AssetSelectArguments
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class AiGenerateImageAction(_ActionBase):
|
| 205 |
+
type: Literal["ai.generate_image"]
|
| 206 |
+
arguments: AiGenerateImageArguments
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
class AiGenerateVideoAction(_ActionBase):
|
| 210 |
+
type: Literal["ai.generate_video"]
|
| 211 |
+
arguments: AiGenerateVideoArguments
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class EditorSplitClipAction(_ActionBase):
|
| 215 |
+
type: Literal["editor.split_clip"]
|
| 216 |
+
arguments: EditorSplitClipArguments
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class EditorDeleteClipAction(_ActionBase):
|
| 220 |
+
type: Literal["editor.delete_clip"]
|
| 221 |
+
arguments: EditorDeleteClipArguments
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class EditorSetDurationAction(_ActionBase):
|
| 225 |
+
type: Literal["editor.set_duration"]
|
| 226 |
+
arguments: EditorSetDurationArguments
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class EditorAddClipAction(_ActionBase):
|
| 230 |
+
type: Literal["editor.add_clip"]
|
| 231 |
+
arguments: EditorAddClipArguments
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class EditorRenderAction(_ActionBase):
|
| 235 |
+
type: Literal["editor.render"]
|
| 236 |
+
arguments: EditorRenderArguments
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
class ShareProjectArguments(BaseModel):
|
| 240 |
+
model_config = ConfigDict(extra="forbid")
|
| 241 |
+
project_id: str
|
| 242 |
+
user_id: str
|
| 243 |
+
role: str
|
| 244 |
+
|
| 245 |
+
class SubmitForReviewArguments(BaseModel):
|
| 246 |
+
model_config = ConfigDict(extra="forbid")
|
| 247 |
+
project_id: str
|
| 248 |
+
workflow_id: str
|
| 249 |
+
|
| 250 |
+
class ProjectActivitySummarizeArguments(BaseModel):
|
| 251 |
+
model_config = ConfigDict(extra="forbid")
|
| 252 |
+
project_id: str
|
| 253 |
+
|
| 254 |
+
class ShareProjectAction(_ActionBase):
|
| 255 |
+
type: Literal["project.share"]
|
| 256 |
+
arguments: ShareProjectArguments
|
| 257 |
+
|
| 258 |
+
class SubmitForReviewAction(_ActionBase):
|
| 259 |
+
type: Literal["review.submit"]
|
| 260 |
+
arguments: SubmitForReviewArguments
|
| 261 |
+
|
| 262 |
+
class ProjectActivitySummarizeAction(_ActionBase):
|
| 263 |
+
type: Literal["project_activity.summarize"]
|
| 264 |
+
arguments: ProjectActivitySummarizeArguments
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class TemplateSearchAction(_ActionBase):
|
| 268 |
+
type: Literal["template.search"]
|
| 269 |
+
arguments: TemplateSearchArguments
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class TemplateGetAction(_ActionBase):
|
| 273 |
+
type: Literal["template.get"]
|
| 274 |
+
arguments: TemplateGetArguments
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class TemplateApplyAction(_ActionBase):
|
| 278 |
+
type: Literal["template.apply"]
|
| 279 |
+
arguments: TemplateApplyArguments
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class TemplateCreateProjectAction(_ActionBase):
|
| 283 |
+
type: Literal["template.create_project"]
|
| 284 |
+
arguments: TemplateCreateProjectArguments
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
class PublishingValidateAction(_ActionBase):
|
| 288 |
+
type: Literal["publishing.validate"]
|
| 289 |
+
arguments: PublishingValidateArguments
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class PublishingCreatePostAction(_ActionBase):
|
| 293 |
+
type: Literal["publishing.create_post"]
|
| 294 |
+
arguments: PublishingCreatePostArguments
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class PublishingScheduleAction(_ActionBase):
|
| 298 |
+
type: Literal["publishing.schedule"]
|
| 299 |
+
arguments: PublishingScheduleArguments
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class PublishingPublishAction(_ActionBase):
|
| 303 |
+
type: Literal["publishing.publish"]
|
| 304 |
+
arguments: PublishingPublishArguments
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
class PublishingCancelAction(_ActionBase):
|
| 308 |
+
type: Literal["publishing.cancel"]
|
| 309 |
+
arguments: PublishingCancelArguments
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
class AnalyticsOverviewAction(_ActionBase):
|
| 313 |
+
type: Literal["analytics.overview"]
|
| 314 |
+
arguments: AnalyticsOverviewArguments
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class AnalyticsSyncAction(_ActionBase):
|
| 318 |
+
type: Literal["analytics.sync"]
|
| 319 |
+
arguments: AnalyticsSyncArguments
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
CopilotAction = Annotated[
|
| 323 |
+
ProjectOpenAction
|
| 324 |
+
| AssetSelectAction
|
| 325 |
+
| AiGenerateImageAction
|
| 326 |
+
| AiGenerateVideoAction
|
| 327 |
+
| EditorSplitClipAction
|
| 328 |
+
| EditorDeleteClipAction
|
| 329 |
+
| EditorSetDurationAction
|
| 330 |
+
| EditorAddClipAction
|
| 331 |
+
| EditorRenderAction
|
| 332 |
+
| ShareProjectAction
|
| 333 |
+
| SubmitForReviewAction
|
| 334 |
+
| ProjectActivitySummarizeAction
|
| 335 |
+
| TemplateSearchAction
|
| 336 |
+
| TemplateGetAction
|
| 337 |
+
| TemplateApplyAction
|
| 338 |
+
| TemplateCreateProjectAction
|
| 339 |
+
| PublishingValidateAction
|
| 340 |
+
| PublishingCreatePostAction
|
| 341 |
+
| PublishingScheduleAction
|
| 342 |
+
| PublishingPublishAction
|
| 343 |
+
| PublishingCancelAction
|
| 344 |
+
| AnalyticsOverviewAction
|
| 345 |
+
| AnalyticsSyncAction,
|
| 346 |
+
Field(discriminator="type"),
|
| 347 |
+
]
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
class CopilotPlan(BaseModel):
|
| 351 |
+
model_config = ConfigDict(extra="forbid")
|
| 352 |
+
|
| 353 |
+
intent: str = Field(min_length=1, max_length=200)
|
| 354 |
+
explanation: str = Field(min_length=1, max_length=1_000)
|
| 355 |
+
actions: list[CopilotAction] = Field(default_factory=list, max_length=20)
|
| 356 |
+
missing_information: list[str] = Field(default_factory=list, max_length=20)
|
| 357 |
+
unsupported_capabilities: list[str] = Field(default_factory=list, max_length=20)
|
| 358 |
+
executable: bool
|
| 359 |
+
requires_confirmation: bool
|
| 360 |
+
|
| 361 |
+
@model_validator(mode="after")
|
| 362 |
+
def validate_execution(self) -> "CopilotPlan":
|
| 363 |
+
if self.executable and not self.actions:
|
| 364 |
+
raise ValueError("Executable plans require at least one action")
|
| 365 |
+
if self.requires_confirmation != any(
|
| 366 |
+
action.requires_confirmation for action in self.actions
|
| 367 |
+
):
|
| 368 |
+
raise ValueError("Plan confirmation state must match its actions")
|
| 369 |
+
return self
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
class CopilotActionResult(BaseModel):
|
| 373 |
+
model_config = ConfigDict(extra="forbid")
|
| 374 |
+
|
| 375 |
+
action_id: str
|
| 376 |
+
action_type: str
|
| 377 |
+
status: CopilotActionStatus
|
| 378 |
+
summary: str = Field(max_length=1_000)
|
| 379 |
+
resource_type: str | None = Field(default=None, max_length=100)
|
| 380 |
+
resource_id: str | None = Field(default=None, max_length=255)
|
| 381 |
+
retryable: bool = False
|
| 382 |
+
error_code: str | None = Field(default=None, max_length=100)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
class CopilotRunCreate(BaseModel):
|
| 386 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 387 |
+
|
| 388 |
+
request: str = Field(min_length=1, max_length=4_000)
|
| 389 |
+
context: CopilotContextInput = Field(default_factory=CopilotContextInput)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
class CopilotExecuteRequest(BaseModel):
|
| 393 |
+
model_config = ConfigDict(extra="forbid")
|
| 394 |
+
confirmed: bool = False
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
class CopilotRun(BaseModel):
|
| 398 |
+
model_config = ConfigDict(extra="forbid")
|
| 399 |
+
|
| 400 |
+
id: str
|
| 401 |
+
project_id: str | None
|
| 402 |
+
status: CopilotRunStatus
|
| 403 |
+
request: str
|
| 404 |
+
context: CopilotContext
|
| 405 |
+
plan: CopilotPlan
|
| 406 |
+
current_action_id: str | None
|
| 407 |
+
results: list[CopilotActionResult]
|
| 408 |
+
summary: str | None
|
| 409 |
+
error_code: str | None
|
| 410 |
+
error_message: str | None
|
| 411 |
+
created_at: datetime
|
| 412 |
+
updated_at: datetime
|
| 413 |
+
completed_at: datetime | None
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
class CopilotRunList(BaseModel):
|
| 417 |
+
items: list[CopilotRun]
|
| 418 |
+
offset: int
|
| 419 |
+
limit: int
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
class CopilotActionCapability(BaseModel):
|
| 423 |
+
model_config = ConfigDict(extra="forbid")
|
| 424 |
+
type: str
|
| 425 |
+
description: str
|
| 426 |
+
required_permission: str
|
| 427 |
+
required_capability: str
|
| 428 |
+
destructive: bool
|
| 429 |
+
external_side_effect: bool
|
| 430 |
+
requires_confirmation: bool
|
| 431 |
+
available: bool
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
class CopilotCapabilities(BaseModel):
|
| 435 |
+
model_config = ConfigDict(extra="forbid")
|
| 436 |
+
available: bool
|
| 437 |
+
planner: Literal["deterministic"]
|
| 438 |
+
actions: list[CopilotActionCapability]
|
| 439 |
+
permissions: list[str]
|
app/copilot/service.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from app.ai.service import AiStudioService
|
| 8 |
+
from app.copilot.actions import CopilotActionRegistry
|
| 9 |
+
from app.copilot.errors import (
|
| 10 |
+
CopilotConfirmationRequiredError,
|
| 11 |
+
CopilotInvalidRequestError,
|
| 12 |
+
CopilotRunConflictError,
|
| 13 |
+
)
|
| 14 |
+
from app.copilot.models import CopilotRunRecord
|
| 15 |
+
from app.copilot.planner import CopilotPlanner
|
| 16 |
+
from app.copilot.repository import CopilotRepository
|
| 17 |
+
from app.copilot.schemas import (
|
| 18 |
+
CopilotCapabilities,
|
| 19 |
+
CopilotContext,
|
| 20 |
+
CopilotContextInput,
|
| 21 |
+
CopilotEditorSummary,
|
| 22 |
+
CopilotExecuteRequest,
|
| 23 |
+
CopilotPlan,
|
| 24 |
+
CopilotRun,
|
| 25 |
+
CopilotRunCreate,
|
| 26 |
+
CopilotRunList,
|
| 27 |
+
)
|
| 28 |
+
from app.core.exceptions import MediaAPIError
|
| 29 |
+
from app.core.logger import get_logger
|
| 30 |
+
from app.projects.errors import ProjectEditorNotFoundError
|
| 31 |
+
from app.projects.services.editor_service import ProjectEditorService
|
| 32 |
+
from app.projects.services.project_service import ProjectService
|
| 33 |
+
from app.security.assets import CanonicalAssetNotFoundError, CanonicalAssetService
|
| 34 |
+
from app.security.audit import AuditService
|
| 35 |
+
|
| 36 |
+
logger = get_logger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class CopilotService:
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
*,
|
| 43 |
+
repository: CopilotRepository,
|
| 44 |
+
planner: CopilotPlanner,
|
| 45 |
+
actions: CopilotActionRegistry,
|
| 46 |
+
projects: ProjectService,
|
| 47 |
+
assets: CanonicalAssetService,
|
| 48 |
+
editor: ProjectEditorService,
|
| 49 |
+
ai: AiStudioService,
|
| 50 |
+
audit: AuditService,
|
| 51 |
+
) -> None:
|
| 52 |
+
self.repository = repository
|
| 53 |
+
self.planner = planner
|
| 54 |
+
self.actions = actions
|
| 55 |
+
self.projects = projects
|
| 56 |
+
self.assets = assets
|
| 57 |
+
self.editor = editor
|
| 58 |
+
self.ai = ai
|
| 59 |
+
self.audit = audit
|
| 60 |
+
|
| 61 |
+
async def capabilities(
|
| 62 |
+
self, *, workspace_id: str, user_id: str, context: CopilotContextInput
|
| 63 |
+
) -> CopilotCapabilities:
|
| 64 |
+
bounded = await self.build_context(
|
| 65 |
+
workspace_id=workspace_id, user_id=user_id, supplied=context
|
| 66 |
+
)
|
| 67 |
+
available = set(bounded.available_capabilities)
|
| 68 |
+
return CopilotCapabilities(
|
| 69 |
+
available=True,
|
| 70 |
+
planner="deterministic",
|
| 71 |
+
actions=self.actions.capabilities(available),
|
| 72 |
+
permissions=["copilot:read", "copilot:execute"],
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
async def build_context(
|
| 76 |
+
self,
|
| 77 |
+
*,
|
| 78 |
+
workspace_id: str,
|
| 79 |
+
user_id: str,
|
| 80 |
+
supplied: CopilotContextInput,
|
| 81 |
+
) -> CopilotContext:
|
| 82 |
+
project_id = str(supplied.project_id) if supplied.project_id else None
|
| 83 |
+
capabilities = {
|
| 84 |
+
"project.open",
|
| 85 |
+
"asset.select",
|
| 86 |
+
"template.search",
|
| 87 |
+
"template.get",
|
| 88 |
+
"template.apply",
|
| 89 |
+
"template.create_project",
|
| 90 |
+
"publishing.validate",
|
| 91 |
+
"publishing.create_post",
|
| 92 |
+
"publishing.schedule",
|
| 93 |
+
"publishing.publish",
|
| 94 |
+
"publishing.cancel",
|
| 95 |
+
"analytics.overview",
|
| 96 |
+
"analytics.sync",
|
| 97 |
+
}
|
| 98 |
+
editor_summary = None
|
| 99 |
+
editor_document = None
|
| 100 |
+
if project_id:
|
| 101 |
+
await self.projects.get(
|
| 102 |
+
workspace_id=workspace_id, user_id=user_id, project_id=project_id
|
| 103 |
+
)
|
| 104 |
+
try:
|
| 105 |
+
editor = await self.editor.get(
|
| 106 |
+
workspace_id=workspace_id,
|
| 107 |
+
user_id=user_id,
|
| 108 |
+
project_id=project_id,
|
| 109 |
+
)
|
| 110 |
+
editor_document = editor.state
|
| 111 |
+
editor_summary = CopilotEditorSummary(
|
| 112 |
+
revision=editor.revision,
|
| 113 |
+
duration_ms=editor.state.duration_ms(),
|
| 114 |
+
track_count=len(editor.state.timeline.tracks),
|
| 115 |
+
clip_count=sum(len(track.clips) for track in editor.state.timeline.tracks),
|
| 116 |
+
)
|
| 117 |
+
capabilities.update(
|
| 118 |
+
{
|
| 119 |
+
"editor.split_clip",
|
| 120 |
+
"editor.delete_clip",
|
| 121 |
+
"editor.set_duration",
|
| 122 |
+
"editor.add_clip",
|
| 123 |
+
"editor.render",
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
except ProjectEditorNotFoundError:
|
| 127 |
+
editor_summary = None
|
| 128 |
+
selected_assets = []
|
| 129 |
+
for asset_id in supplied.selected_asset_ids:
|
| 130 |
+
try:
|
| 131 |
+
asset = await self.assets.get_owned_by_id(
|
| 132 |
+
workspace_id=workspace_id,
|
| 133 |
+
user_id=user_id,
|
| 134 |
+
asset_id=str(asset_id),
|
| 135 |
+
)
|
| 136 |
+
except CanonicalAssetNotFoundError as exc:
|
| 137 |
+
raise CopilotInvalidRequestError(
|
| 138 |
+
"A selected asset is not available in this workspace."
|
| 139 |
+
) from exc
|
| 140 |
+
if project_id and asset.project_id != project_id:
|
| 141 |
+
raise CopilotInvalidRequestError(
|
| 142 |
+
"Every selected asset must belong to the selected project."
|
| 143 |
+
)
|
| 144 |
+
selected_assets.append(asset.id)
|
| 145 |
+
selected_clips = list(dict.fromkeys(supplied.selected_clip_ids))
|
| 146 |
+
if selected_clips:
|
| 147 |
+
if editor_document is None:
|
| 148 |
+
raise CopilotInvalidRequestError("Selected clips require saved editor state.")
|
| 149 |
+
known = {clip.id for track in editor_document.timeline.tracks for clip in track.clips}
|
| 150 |
+
if any(clip_id not in known for clip_id in selected_clips):
|
| 151 |
+
raise CopilotInvalidRequestError(
|
| 152 |
+
"A selected clip is not present in the authoritative editor state."
|
| 153 |
+
)
|
| 154 |
+
ai_capabilities = self.ai.capabilities()
|
| 155 |
+
for tool in ai_capabilities.tools:
|
| 156 |
+
if tool.available:
|
| 157 |
+
capabilities.add(f"ai.{tool.operation.removeprefix('generate_')}")
|
| 158 |
+
capabilities.add(f"ai.{tool.operation}")
|
| 159 |
+
return CopilotContext(
|
| 160 |
+
workspace_id=workspace_id,
|
| 161 |
+
project_id=supplied.project_id,
|
| 162 |
+
selected_asset_ids=selected_assets,
|
| 163 |
+
selected_clip_ids=selected_clips,
|
| 164 |
+
active_tool=supplied.active_tool,
|
| 165 |
+
editor_summary=editor_summary,
|
| 166 |
+
available_capabilities=sorted(capabilities),
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
async def create_run(
|
| 170 |
+
self,
|
| 171 |
+
*,
|
| 172 |
+
workspace_id: str,
|
| 173 |
+
user_id: str,
|
| 174 |
+
api_key_id: str,
|
| 175 |
+
request_id: str,
|
| 176 |
+
payload: CopilotRunCreate,
|
| 177 |
+
idempotency_key: str,
|
| 178 |
+
) -> CopilotRun:
|
| 179 |
+
key = idempotency_key.strip()
|
| 180 |
+
if not key or len(key) > 255:
|
| 181 |
+
raise CopilotInvalidRequestError("A bounded Idempotency-Key is required.")
|
| 182 |
+
context = await self.build_context(
|
| 183 |
+
workspace_id=workspace_id, user_id=user_id, supplied=payload.context
|
| 184 |
+
)
|
| 185 |
+
fingerprint = hashlib.sha256(
|
| 186 |
+
json.dumps(
|
| 187 |
+
{
|
| 188 |
+
"request": payload.request,
|
| 189 |
+
"context": context.model_dump(mode="json"),
|
| 190 |
+
},
|
| 191 |
+
sort_keys=True,
|
| 192 |
+
separators=(",", ":"),
|
| 193 |
+
).encode()
|
| 194 |
+
).hexdigest()
|
| 195 |
+
plan = self.planner.plan(payload.request, context)
|
| 196 |
+
self.actions.validate_plan(plan.actions)
|
| 197 |
+
record = CopilotRunRecord(
|
| 198 |
+
workspace_id=workspace_id,
|
| 199 |
+
user_id=user_id,
|
| 200 |
+
project_id=str(context.project_id) if context.project_id else None,
|
| 201 |
+
idempotency_key=key,
|
| 202 |
+
request_fingerprint=fingerprint,
|
| 203 |
+
request_text=payload.request,
|
| 204 |
+
context_json=context.model_dump(mode="json"),
|
| 205 |
+
plan_json=plan.model_dump(mode="json"),
|
| 206 |
+
status="plan_ready" if plan.executable else "blocked",
|
| 207 |
+
results_json=[],
|
| 208 |
+
)
|
| 209 |
+
created, is_new = await self.repository.create(record)
|
| 210 |
+
if is_new:
|
| 211 |
+
await self.audit.record_event(
|
| 212 |
+
workspace_id=workspace_id,
|
| 213 |
+
user_id=user_id,
|
| 214 |
+
api_key_id=api_key_id,
|
| 215 |
+
request_id=request_id,
|
| 216 |
+
event_type="copilot.run_started",
|
| 217 |
+
entity_type="copilot_run",
|
| 218 |
+
entity_id=created.id,
|
| 219 |
+
metadata={"project_id": created.project_id},
|
| 220 |
+
)
|
| 221 |
+
await self.audit.record_event(
|
| 222 |
+
workspace_id=workspace_id,
|
| 223 |
+
user_id=user_id,
|
| 224 |
+
api_key_id=api_key_id,
|
| 225 |
+
request_id=request_id,
|
| 226 |
+
event_type="copilot.plan_created",
|
| 227 |
+
entity_type="copilot_run",
|
| 228 |
+
entity_id=created.id,
|
| 229 |
+
metadata={
|
| 230 |
+
"action_count": len(plan.actions),
|
| 231 |
+
"requires_confirmation": plan.requires_confirmation,
|
| 232 |
+
"executable": plan.executable,
|
| 233 |
+
},
|
| 234 |
+
)
|
| 235 |
+
logger.info(
|
| 236 |
+
"copilot plan created",
|
| 237 |
+
extra={
|
| 238 |
+
"run_id": created.id,
|
| 239 |
+
"project_id": created.project_id,
|
| 240 |
+
"status": created.status,
|
| 241 |
+
"action_count": len(plan.actions),
|
| 242 |
+
},
|
| 243 |
+
)
|
| 244 |
+
return self._response(created)
|
| 245 |
+
|
| 246 |
+
async def execute(
|
| 247 |
+
self,
|
| 248 |
+
*,
|
| 249 |
+
workspace_id: str,
|
| 250 |
+
user_id: str,
|
| 251 |
+
api_key_id: str,
|
| 252 |
+
request_id: str,
|
| 253 |
+
run_id: str,
|
| 254 |
+
payload: CopilotExecuteRequest,
|
| 255 |
+
permissions: frozenset[str],
|
| 256 |
+
) -> CopilotRun:
|
| 257 |
+
record = await self.repository.get(workspace_id, user_id, run_id)
|
| 258 |
+
if record.status not in {"plan_ready"}:
|
| 259 |
+
raise CopilotRunConflictError("Only a plan-ready Copilot run can be executed.")
|
| 260 |
+
plan = CopilotPlan.model_validate(record.plan_json)
|
| 261 |
+
self.actions.validate_plan(plan.actions)
|
| 262 |
+
if not plan.executable:
|
| 263 |
+
raise CopilotRunConflictError("This Copilot plan is not executable.")
|
| 264 |
+
if plan.requires_confirmation and not payload.confirmed:
|
| 265 |
+
raise CopilotConfirmationRequiredError(
|
| 266 |
+
"Explicit confirmation is required before this plan can run."
|
| 267 |
+
)
|
| 268 |
+
record = await self.repository.claim_execution(
|
| 269 |
+
workspace_id,
|
| 270 |
+
user_id,
|
| 271 |
+
run_id,
|
| 272 |
+
confirmed=payload.confirmed,
|
| 273 |
+
)
|
| 274 |
+
results = []
|
| 275 |
+
started = time.monotonic()
|
| 276 |
+
available = set(CopilotContext.model_validate(record.context_json).available_capabilities)
|
| 277 |
+
for action in plan.actions:
|
| 278 |
+
await self.repository.update(
|
| 279 |
+
workspace_id,
|
| 280 |
+
user_id,
|
| 281 |
+
run_id,
|
| 282 |
+
status="executing",
|
| 283 |
+
current_action_id=action.id,
|
| 284 |
+
results=[item.model_dump(mode="json") for item in results],
|
| 285 |
+
)
|
| 286 |
+
await self.audit.record_event(
|
| 287 |
+
workspace_id=workspace_id,
|
| 288 |
+
user_id=user_id,
|
| 289 |
+
api_key_id=api_key_id,
|
| 290 |
+
request_id=request_id,
|
| 291 |
+
event_type="copilot.action_started",
|
| 292 |
+
entity_type="copilot_run",
|
| 293 |
+
entity_id=run_id,
|
| 294 |
+
metadata={"action_id": action.id, "action_type": action.type},
|
| 295 |
+
)
|
| 296 |
+
try:
|
| 297 |
+
result = await self.actions.execute(
|
| 298 |
+
action,
|
| 299 |
+
workspace_id=workspace_id,
|
| 300 |
+
user_id=user_id,
|
| 301 |
+
api_key_id=api_key_id,
|
| 302 |
+
request_id=request_id,
|
| 303 |
+
run_id=run_id,
|
| 304 |
+
permissions=permissions,
|
| 305 |
+
available_capabilities=available,
|
| 306 |
+
)
|
| 307 |
+
results.append(result)
|
| 308 |
+
await self.audit.record_event(
|
| 309 |
+
workspace_id=workspace_id,
|
| 310 |
+
user_id=user_id,
|
| 311 |
+
api_key_id=api_key_id,
|
| 312 |
+
request_id=request_id,
|
| 313 |
+
event_type="copilot.action_completed",
|
| 314 |
+
entity_type="copilot_run",
|
| 315 |
+
entity_id=run_id,
|
| 316 |
+
metadata={
|
| 317 |
+
"action_id": action.id,
|
| 318 |
+
"action_type": action.type,
|
| 319 |
+
"resource_type": result.resource_type,
|
| 320 |
+
"resource_id": result.resource_id,
|
| 321 |
+
},
|
| 322 |
+
)
|
| 323 |
+
except Exception as exc:
|
| 324 |
+
if isinstance(exc, MediaAPIError):
|
| 325 |
+
error_code = exc.code
|
| 326 |
+
error_message = exc.message
|
| 327 |
+
else:
|
| 328 |
+
error_code = "COPILOT_ACTION_FAILED"
|
| 329 |
+
error_message = "The Copilot action failed unexpectedly."
|
| 330 |
+
logger.error(
|
| 331 |
+
"copilot action failed unexpectedly",
|
| 332 |
+
extra={
|
| 333 |
+
"run_id": run_id,
|
| 334 |
+
"project_id": record.project_id,
|
| 335 |
+
"action_id": action.id,
|
| 336 |
+
"action_type": action.type,
|
| 337 |
+
"error_category": error_code,
|
| 338 |
+
},
|
| 339 |
+
)
|
| 340 |
+
results.append(
|
| 341 |
+
self._failed_result(
|
| 342 |
+
action.id,
|
| 343 |
+
action.type,
|
| 344 |
+
error_message,
|
| 345 |
+
error_code,
|
| 346 |
+
)
|
| 347 |
+
)
|
| 348 |
+
await self.audit.record_event(
|
| 349 |
+
workspace_id=workspace_id,
|
| 350 |
+
user_id=user_id,
|
| 351 |
+
api_key_id=api_key_id,
|
| 352 |
+
request_id=request_id,
|
| 353 |
+
event_type="copilot.action_failed",
|
| 354 |
+
entity_type="copilot_run",
|
| 355 |
+
entity_id=run_id,
|
| 356 |
+
metadata={
|
| 357 |
+
"action_id": action.id,
|
| 358 |
+
"action_type": action.type,
|
| 359 |
+
"error_code": error_code,
|
| 360 |
+
},
|
| 361 |
+
)
|
| 362 |
+
break
|
| 363 |
+
completed = sum(item.status == "completed" for item in results)
|
| 364 |
+
failed = sum(item.status == "failed" for item in results)
|
| 365 |
+
if failed and completed:
|
| 366 |
+
status = "partial"
|
| 367 |
+
summary = (
|
| 368 |
+
f"{completed} of {len(plan.actions)} actions completed; "
|
| 369 |
+
"the remaining workflow stopped after a failure."
|
| 370 |
+
)
|
| 371 |
+
event = "copilot.run_failed"
|
| 372 |
+
elif failed:
|
| 373 |
+
status = "failed"
|
| 374 |
+
summary = "The Copilot action failed before the workflow completed."
|
| 375 |
+
event = "copilot.run_failed"
|
| 376 |
+
else:
|
| 377 |
+
status = "completed"
|
| 378 |
+
summary = f"{completed} action{'s' if completed != 1 else ''} completed."
|
| 379 |
+
event = "copilot.run_completed"
|
| 380 |
+
updated = await self.repository.update(
|
| 381 |
+
workspace_id,
|
| 382 |
+
user_id,
|
| 383 |
+
run_id,
|
| 384 |
+
status=status,
|
| 385 |
+
current_action_id=None,
|
| 386 |
+
results=[item.model_dump(mode="json") for item in results],
|
| 387 |
+
summary=summary,
|
| 388 |
+
error_code=results[-1].error_code if failed else None,
|
| 389 |
+
error_message=results[-1].summary if failed else None,
|
| 390 |
+
terminal=True,
|
| 391 |
+
)
|
| 392 |
+
await self.audit.record_event(
|
| 393 |
+
workspace_id=workspace_id,
|
| 394 |
+
user_id=user_id,
|
| 395 |
+
api_key_id=api_key_id,
|
| 396 |
+
request_id=request_id,
|
| 397 |
+
event_type=event,
|
| 398 |
+
entity_type="copilot_run",
|
| 399 |
+
entity_id=run_id,
|
| 400 |
+
metadata={
|
| 401 |
+
"status": status,
|
| 402 |
+
"completed_actions": completed,
|
| 403 |
+
"failed_actions": failed,
|
| 404 |
+
},
|
| 405 |
+
)
|
| 406 |
+
logger.info(
|
| 407 |
+
"copilot run finished",
|
| 408 |
+
extra={
|
| 409 |
+
"run_id": run_id,
|
| 410 |
+
"project_id": record.project_id,
|
| 411 |
+
"status": status,
|
| 412 |
+
"duration_ms": round((time.monotonic() - started) * 1_000),
|
| 413 |
+
},
|
| 414 |
+
)
|
| 415 |
+
return self._response(updated)
|
| 416 |
+
|
| 417 |
+
async def cancel(
|
| 418 |
+
self,
|
| 419 |
+
*,
|
| 420 |
+
workspace_id: str,
|
| 421 |
+
user_id: str,
|
| 422 |
+
api_key_id: str,
|
| 423 |
+
request_id: str,
|
| 424 |
+
run_id: str,
|
| 425 |
+
) -> CopilotRun:
|
| 426 |
+
updated, changed = await self.repository.cancel_before_execution(
|
| 427 |
+
workspace_id,
|
| 428 |
+
user_id,
|
| 429 |
+
run_id,
|
| 430 |
+
)
|
| 431 |
+
if not changed:
|
| 432 |
+
return self._response(updated)
|
| 433 |
+
await self.audit.record_event(
|
| 434 |
+
workspace_id=workspace_id,
|
| 435 |
+
user_id=user_id,
|
| 436 |
+
api_key_id=api_key_id,
|
| 437 |
+
request_id=request_id,
|
| 438 |
+
event_type="copilot.run_cancelled",
|
| 439 |
+
entity_type="copilot_run",
|
| 440 |
+
entity_id=run_id,
|
| 441 |
+
metadata={"project_id": updated.project_id},
|
| 442 |
+
)
|
| 443 |
+
return self._response(updated)
|
| 444 |
+
|
| 445 |
+
async def get(self, *, workspace_id: str, user_id: str, run_id: str) -> CopilotRun:
|
| 446 |
+
return self._response(await self.repository.get(workspace_id, user_id, run_id))
|
| 447 |
+
|
| 448 |
+
async def list(
|
| 449 |
+
self,
|
| 450 |
+
*,
|
| 451 |
+
workspace_id: str,
|
| 452 |
+
user_id: str,
|
| 453 |
+
offset: int,
|
| 454 |
+
limit: int,
|
| 455 |
+
) -> CopilotRunList:
|
| 456 |
+
records = await self.repository.list(workspace_id, user_id, offset=offset, limit=limit)
|
| 457 |
+
return CopilotRunList(
|
| 458 |
+
items=[self._response(record) for record in records],
|
| 459 |
+
offset=offset,
|
| 460 |
+
limit=limit,
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
@staticmethod
|
| 464 |
+
def _failed_result(action_id: str, action_type: str, message: str, error_code: str):
|
| 465 |
+
from app.copilot.schemas import CopilotActionResult
|
| 466 |
+
|
| 467 |
+
return CopilotActionResult(
|
| 468 |
+
action_id=action_id,
|
| 469 |
+
action_type=action_type,
|
| 470 |
+
status="failed",
|
| 471 |
+
summary=message[:1_000],
|
| 472 |
+
error_code=error_code,
|
| 473 |
+
retryable=isinstance(error_code, str)
|
| 474 |
+
and error_code
|
| 475 |
+
in {
|
| 476 |
+
"GENERATION_PROVIDER_UNAVAILABLE",
|
| 477 |
+
"RATE_LIMIT_EXCEEDED",
|
| 478 |
+
"PROJECT_EDITOR_REVISION_CONFLICT",
|
| 479 |
+
},
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
@staticmethod
|
| 483 |
+
def _response(record: CopilotRunRecord) -> CopilotRun:
|
| 484 |
+
return CopilotRun(
|
| 485 |
+
id=record.id,
|
| 486 |
+
project_id=record.project_id,
|
| 487 |
+
status=record.status,
|
| 488 |
+
request=record.request_text,
|
| 489 |
+
context=CopilotContext.model_validate(record.context_json),
|
| 490 |
+
plan=CopilotPlan.model_validate(record.plan_json),
|
| 491 |
+
current_action_id=record.current_action_id,
|
| 492 |
+
results=record.results_json or [],
|
| 493 |
+
summary=record.summary,
|
| 494 |
+
error_code=record.error_code,
|
| 495 |
+
error_message=record.error_message,
|
| 496 |
+
created_at=record.created_at,
|
| 497 |
+
updated_at=record.updated_at,
|
| 498 |
+
completed_at=record.completed_at,
|
| 499 |
+
)
|
app/core/config.py
CHANGED
|
@@ -5,7 +5,7 @@ from functools import lru_cache
|
|
| 5 |
from pathlib import Path
|
| 6 |
from urllib.parse import urlparse
|
| 7 |
|
| 8 |
-
from pydantic import Field, SecretStr, field_validator
|
| 9 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 10 |
|
| 11 |
DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories"
|
|
@@ -20,8 +20,10 @@ class Settings(BaseSettings):
|
|
| 20 |
|
| 21 |
app_name: str = "MediaRouter"
|
| 22 |
app_version: str = "1.0.0"
|
|
|
|
| 23 |
host: str = "0.0.0.0"
|
| 24 |
port: int = 7860
|
|
|
|
| 25 |
temp_dir: Path = Path("./temp")
|
| 26 |
output_dir: Path = Path("./outputs")
|
| 27 |
template_dir: Path = DEFAULT_TEMPLATE_DIR
|
|
@@ -40,6 +42,11 @@ class Settings(BaseSettings):
|
|
| 40 |
ffprobe_binary: str = "ffprobe"
|
| 41 |
auth_enabled: bool = True
|
| 42 |
database_url: str = "sqlite+aiosqlite:///./data/mediarouter.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
auth_role_scopes: dict[str, list[str]] = Field(default_factory=dict)
|
| 44 |
auth_bootstrap_key_hash: str = ""
|
| 45 |
auth_bootstrap_key_prefix: str = ""
|
|
@@ -49,9 +56,7 @@ class Settings(BaseSettings):
|
|
| 49 |
auth_default_requests_per_minute: int = Field(default=100, ge=1, le=1_000_000)
|
| 50 |
auth_default_concurrent_jobs: int = Field(default=10, ge=1, le=10_000)
|
| 51 |
auth_default_uploads_per_hour: int = Field(default=20, ge=1, le=1_000_000)
|
| 52 |
-
auth_default_processing_bytes_per_day: int = Field(
|
| 53 |
-
default=107_374_182_400, ge=1_048_576
|
| 54 |
-
)
|
| 55 |
auth_trust_proxy_headers: bool = True
|
| 56 |
mcp_stdio_api_key: SecretStr | None = None
|
| 57 |
# Social Automation foundation. The existing database remains the default
|
|
@@ -59,6 +64,12 @@ class Settings(BaseSettings):
|
|
| 59 |
# dedicated async SQLAlchemy URL and apply the SQL migration out-of-band.
|
| 60 |
social_enabled: bool = True
|
| 61 |
social_database_url: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
social_auto_migrate: bool = False
|
| 63 |
social_worker_enabled: bool = True
|
| 64 |
social_scheduler_interval_seconds: int = Field(default=30, ge=5, le=3600)
|
|
@@ -94,9 +105,7 @@ class Settings(BaseSettings):
|
|
| 94 |
# Direct Post requires TikTok Content Posting approval and an audited app.
|
| 95 |
# Keep it fail-closed until an operator has confirmed that access.
|
| 96 |
tiktok_direct_post_enabled: bool = False
|
| 97 |
-
tiktok_upload_chunk_bytes: int = Field(
|
| 98 |
-
default=10_000_000, ge=5_000_000, le=64_000_000
|
| 99 |
-
)
|
| 100 |
tiktok_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600)
|
| 101 |
tiktok_processing_poll_seconds: int = Field(default=30, ge=5, le=3600)
|
| 102 |
linkedin_client_id: str = ""
|
|
@@ -107,15 +116,9 @@ class Settings(BaseSettings):
|
|
| 107 |
# the operator has confirmed the application products and write scopes in
|
| 108 |
# LinkedIn Developer Portal.
|
| 109 |
linkedin_publishing_enabled: bool = False
|
| 110 |
-
linkedin_request_timeout_seconds: float = Field(
|
| 111 |
-
|
| 112 |
-
)
|
| 113 |
-
linkedin_media_processing_poll_seconds: int = Field(
|
| 114 |
-
default=5, ge=1, le=300
|
| 115 |
-
)
|
| 116 |
-
linkedin_media_processing_timeout_seconds: int = Field(
|
| 117 |
-
default=600, ge=30, le=3600
|
| 118 |
-
)
|
| 119 |
x_client_id: str = ""
|
| 120 |
x_client_secret: SecretStr | None = None
|
| 121 |
# Exact OAuth 2.0 callback registered for the confidential X Web App.
|
|
@@ -132,6 +135,45 @@ class Settings(BaseSettings):
|
|
| 132 |
whatsapp_client_id: str = ""
|
| 133 |
whatsapp_client_secret: SecretStr | None = None
|
| 134 |
social_oauth_redirect_base_url: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
@field_validator("whisper_model")
|
| 137 |
@classmethod
|
|
@@ -150,6 +192,107 @@ class Settings(BaseSettings):
|
|
| 150 |
raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(allowed))}")
|
| 151 |
return normalized
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
def ensure_directories(self) -> None:
|
| 154 |
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 155 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -245,9 +388,7 @@ class Settings(BaseSettings):
|
|
| 245 |
or (parsed.scheme == "http" and parsed.hostname in local_hosts)
|
| 246 |
)
|
| 247 |
):
|
| 248 |
-
raise ValueError(
|
| 249 |
-
"X_REDIRECT_URI must be the HTTPS MediaRouter X callback URI"
|
| 250 |
-
)
|
| 251 |
return normalized
|
| 252 |
|
| 253 |
@field_validator("linkedin_redirect_uri")
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from urllib.parse import urlparse
|
| 7 |
|
| 8 |
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
| 9 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 10 |
|
| 11 |
DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories"
|
|
|
|
| 20 |
|
| 21 |
app_name: str = "MediaRouter"
|
| 22 |
app_version: str = "1.0.0"
|
| 23 |
+
app_environment: str = "development"
|
| 24 |
host: str = "0.0.0.0"
|
| 25 |
port: int = 7860
|
| 26 |
+
cors_allowed_origins: str = ""
|
| 27 |
temp_dir: Path = Path("./temp")
|
| 28 |
output_dir: Path = Path("./outputs")
|
| 29 |
template_dir: Path = DEFAULT_TEMPLATE_DIR
|
|
|
|
| 42 |
ffprobe_binary: str = "ffprobe"
|
| 43 |
auth_enabled: bool = True
|
| 44 |
database_url: str = "sqlite+aiosqlite:///./data/mediarouter.db"
|
| 45 |
+
# Security/tenant schema creation is automatic only for SQLite local
|
| 46 |
+
# development. PostgreSQL deployments must apply SQL migrations explicitly.
|
| 47 |
+
security_auto_migrate: bool = False
|
| 48 |
+
security_database_role: str = ""
|
| 49 |
+
security_enforce_rls: bool = True
|
| 50 |
auth_role_scopes: dict[str, list[str]] = Field(default_factory=dict)
|
| 51 |
auth_bootstrap_key_hash: str = ""
|
| 52 |
auth_bootstrap_key_prefix: str = ""
|
|
|
|
| 56 |
auth_default_requests_per_minute: int = Field(default=100, ge=1, le=1_000_000)
|
| 57 |
auth_default_concurrent_jobs: int = Field(default=10, ge=1, le=10_000)
|
| 58 |
auth_default_uploads_per_hour: int = Field(default=20, ge=1, le=1_000_000)
|
| 59 |
+
auth_default_processing_bytes_per_day: int = Field(default=107_374_182_400, ge=1_048_576)
|
|
|
|
|
|
|
| 60 |
auth_trust_proxy_headers: bool = True
|
| 61 |
mcp_stdio_api_key: SecretStr | None = None
|
| 62 |
# Social Automation foundation. The existing database remains the default
|
|
|
|
| 64 |
# dedicated async SQLAlchemy URL and apply the SQL migration out-of-band.
|
| 65 |
social_enabled: bool = True
|
| 66 |
social_database_url: str = ""
|
| 67 |
+
# API requests use SOCIAL_DATABASE_URL with a non-BYPASSRLS role. Workers
|
| 68 |
+
# use a separate, backend-only connection with the trusted role below.
|
| 69 |
+
social_worker_database_url: str = ""
|
| 70 |
+
social_tenant_database_role: str = ""
|
| 71 |
+
social_worker_database_role: str = ""
|
| 72 |
+
social_enforce_rls: bool = True
|
| 73 |
social_auto_migrate: bool = False
|
| 74 |
social_worker_enabled: bool = True
|
| 75 |
social_scheduler_interval_seconds: int = Field(default=30, ge=5, le=3600)
|
|
|
|
| 105 |
# Direct Post requires TikTok Content Posting approval and an audited app.
|
| 106 |
# Keep it fail-closed until an operator has confirmed that access.
|
| 107 |
tiktok_direct_post_enabled: bool = False
|
| 108 |
+
tiktok_upload_chunk_bytes: int = Field(default=10_000_000, ge=5_000_000, le=64_000_000)
|
|
|
|
|
|
|
| 109 |
tiktok_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600)
|
| 110 |
tiktok_processing_poll_seconds: int = Field(default=30, ge=5, le=3600)
|
| 111 |
linkedin_client_id: str = ""
|
|
|
|
| 116 |
# the operator has confirmed the application products and write scopes in
|
| 117 |
# LinkedIn Developer Portal.
|
| 118 |
linkedin_publishing_enabled: bool = False
|
| 119 |
+
linkedin_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600)
|
| 120 |
+
linkedin_media_processing_poll_seconds: int = Field(default=5, ge=1, le=300)
|
| 121 |
+
linkedin_media_processing_timeout_seconds: int = Field(default=600, ge=30, le=3600)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
x_client_id: str = ""
|
| 123 |
x_client_secret: SecretStr | None = None
|
| 124 |
# Exact OAuth 2.0 callback registered for the confidential X Web App.
|
|
|
|
| 135 |
whatsapp_client_id: str = ""
|
| 136 |
whatsapp_client_secret: SecretStr | None = None
|
| 137 |
social_oauth_redirect_base_url: str = ""
|
| 138 |
+
# Provider-neutral generation runtime. Providers remain optional and their
|
| 139 |
+
# worker endpoints/tokens stay server-side only.
|
| 140 |
+
generation_enabled: bool = True
|
| 141 |
+
generation_job_retry_limit: int = Field(default=3, ge=0, le=20)
|
| 142 |
+
# Shared remote-generation worker transport defaults. These do not enable
|
| 143 |
+
# a provider and intentionally contain no worker URL or credentials.
|
| 144 |
+
ai_worker_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=300)
|
| 145 |
+
ai_worker_request_timeout_seconds: float = Field(default=60.0, gt=0, le=3600)
|
| 146 |
+
ai_worker_read_timeout_seconds: float = Field(default=300.0, gt=0, le=7200)
|
| 147 |
+
ai_worker_max_retries: int = Field(default=3, ge=0, le=10)
|
| 148 |
+
ai_worker_retry_backoff_seconds: float = Field(default=0.5, ge=0, le=60)
|
| 149 |
+
# WAN is optional. These values remain backend-only and are deliberately
|
| 150 |
+
# not validated at Settings construction time: a bad optional worker
|
| 151 |
+
# configuration must leave WAN unavailable without preventing unrelated
|
| 152 |
+
# MediaRouter services from starting.
|
| 153 |
+
wan_space_url: str = ""
|
| 154 |
+
wan_space_token: SecretStr | None = None
|
| 155 |
+
# Optional authenticated FLUX.2 Klein worker. Invalid configuration keeps
|
| 156 |
+
# FLUX unavailable without affecting startup or other providers.
|
| 157 |
+
flux_space_url: str = ""
|
| 158 |
+
flux_space_token: SecretStr | None = None
|
| 159 |
+
generation_worker_enabled: bool = True
|
| 160 |
+
generation_worker_interval_seconds: float = Field(default=5.0, ge=0.5, le=3600)
|
| 161 |
+
generation_worker_poll_backoff_seconds: float = Field(default=2.0, ge=0.5, le=300)
|
| 162 |
+
generation_worker_batch_size: int = Field(default=8, ge=1, le=100)
|
| 163 |
+
generation_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400)
|
| 164 |
+
# Content Studio persistence/render safety limits. Rendering is optional
|
| 165 |
+
# infrastructure; disabling its worker never prevents API startup.
|
| 166 |
+
editor_state_max_bytes: int = Field(default=1_048_576, ge=16_384, le=16_777_216)
|
| 167 |
+
render_worker_enabled: bool = True
|
| 168 |
+
render_worker_interval_seconds: float = Field(default=2.0, ge=0.5, le=3600)
|
| 169 |
+
render_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400)
|
| 170 |
+
render_job_timeout_seconds: int = Field(default=7200, ge=60, le=86_400)
|
| 171 |
+
render_job_retry_limit: int = Field(default=2, ge=0, le=10)
|
| 172 |
+
render_max_active_jobs_per_project: int = Field(default=1, ge=1, le=10)
|
| 173 |
+
render_max_tracks: int = Field(default=32, ge=1, le=256)
|
| 174 |
+
render_max_clips: int = Field(default=500, ge=1, le=10_000)
|
| 175 |
+
render_max_duration_seconds: int = Field(default=3600, ge=1, le=21_600)
|
| 176 |
+
render_max_input_bytes: int = Field(default=4_294_967_296, ge=1_048_576)
|
| 177 |
|
| 178 |
@field_validator("whisper_model")
|
| 179 |
@classmethod
|
|
|
|
| 192 |
raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(allowed))}")
|
| 193 |
return normalized
|
| 194 |
|
| 195 |
+
@field_validator("app_environment")
|
| 196 |
+
@classmethod
|
| 197 |
+
def normalize_app_environment(cls, value: str) -> str:
|
| 198 |
+
normalized = value.strip().lower()
|
| 199 |
+
if normalized not in {"development", "test", "production"}:
|
| 200 |
+
raise ValueError("APP_ENVIRONMENT must be development, test, or production")
|
| 201 |
+
return normalized
|
| 202 |
+
|
| 203 |
+
@property
|
| 204 |
+
def allowed_cors_origins(self) -> tuple[str, ...]:
|
| 205 |
+
"""Return validated, normalized origins for Starlette CORS middleware."""
|
| 206 |
+
|
| 207 |
+
origins: list[str] = []
|
| 208 |
+
for configured in self.cors_allowed_origins.split(","):
|
| 209 |
+
origin = configured.strip().rstrip("/")
|
| 210 |
+
if not origin:
|
| 211 |
+
continue
|
| 212 |
+
parsed = urlparse(origin)
|
| 213 |
+
if (
|
| 214 |
+
"*" in origin
|
| 215 |
+
or parsed.scheme not in {"http", "https"}
|
| 216 |
+
or not parsed.netloc
|
| 217 |
+
or parsed.username
|
| 218 |
+
or parsed.password
|
| 219 |
+
or parsed.path
|
| 220 |
+
or parsed.params
|
| 221 |
+
or parsed.query
|
| 222 |
+
or parsed.fragment
|
| 223 |
+
):
|
| 224 |
+
raise ValueError(
|
| 225 |
+
"CORS_ALLOWED_ORIGINS must contain comma-separated HTTP(S) origins"
|
| 226 |
+
)
|
| 227 |
+
if origin not in origins:
|
| 228 |
+
origins.append(origin)
|
| 229 |
+
return tuple(origins)
|
| 230 |
+
|
| 231 |
+
@model_validator(mode="after")
|
| 232 |
+
def validate_production_contract(self) -> Settings:
|
| 233 |
+
"""Fail clearly when the Docker production boundary is unsafe.
|
| 234 |
+
|
| 235 |
+
Local development and tests retain the established SQLite defaults.
|
| 236 |
+
The production Dockerfile sets ``APP_ENVIRONMENT=production``, making
|
| 237 |
+
external PostgreSQL, RLS, explicit migrations, authentication, and an
|
| 238 |
+
exact frontend CORS origin mandatory at process import/startup.
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
origins = self.allowed_cors_origins
|
| 242 |
+
if self.app_environment != "production":
|
| 243 |
+
return self
|
| 244 |
+
|
| 245 |
+
errors: list[str] = []
|
| 246 |
+
if not self._is_external_postgres(self.database_url):
|
| 247 |
+
errors.append("DATABASE_URL must use an external PostgreSQL database in production")
|
| 248 |
+
if self.security_auto_migrate:
|
| 249 |
+
errors.append("SECURITY_AUTO_MIGRATE must be false in production")
|
| 250 |
+
if not self.security_enforce_rls:
|
| 251 |
+
errors.append("SECURITY_ENFORCE_RLS must be true in production")
|
| 252 |
+
if not self.security_database_role.strip():
|
| 253 |
+
errors.append("SECURITY_DATABASE_ROLE is required in production")
|
| 254 |
+
if not self.auth_enabled:
|
| 255 |
+
errors.append("AUTH_ENABLED must be true in production")
|
| 256 |
+
if not origins:
|
| 257 |
+
errors.append("CORS_ALLOWED_ORIGINS must include the HTTPS Vercel frontend origin")
|
| 258 |
+
elif any(urlparse(origin).scheme != "https" for origin in origins):
|
| 259 |
+
errors.append("CORS_ALLOWED_ORIGINS must use HTTPS in production")
|
| 260 |
+
|
| 261 |
+
if self.social_auto_migrate:
|
| 262 |
+
errors.append("SOCIAL_AUTO_MIGRATE must be false in production")
|
| 263 |
+
if self.social_enabled:
|
| 264 |
+
if not self.social_database_url.strip() or not self._is_external_postgres(
|
| 265 |
+
self.social_database_url
|
| 266 |
+
):
|
| 267 |
+
errors.append(
|
| 268 |
+
"SOCIAL_DATABASE_URL must use an explicit external PostgreSQL tenant connection"
|
| 269 |
+
)
|
| 270 |
+
if not self.social_tenant_database_role.strip():
|
| 271 |
+
errors.append("SOCIAL_TENANT_DATABASE_ROLE is required when social is enabled")
|
| 272 |
+
if not self._is_external_postgres(self.social_worker_database_url):
|
| 273 |
+
errors.append(
|
| 274 |
+
"SOCIAL_WORKER_DATABASE_URL must use an external PostgreSQL worker connection"
|
| 275 |
+
)
|
| 276 |
+
if not self.social_worker_database_role.strip():
|
| 277 |
+
errors.append("SOCIAL_WORKER_DATABASE_ROLE is required when social is enabled")
|
| 278 |
+
if not self.social_enforce_rls:
|
| 279 |
+
errors.append("SOCIAL_ENFORCE_RLS must be true when social is enabled")
|
| 280 |
+
|
| 281 |
+
if errors:
|
| 282 |
+
raise ValueError("Invalid production configuration: " + "; ".join(errors))
|
| 283 |
+
return self
|
| 284 |
+
|
| 285 |
+
@staticmethod
|
| 286 |
+
def _is_external_postgres(value: str) -> bool:
|
| 287 |
+
configured = value.strip()
|
| 288 |
+
if not configured:
|
| 289 |
+
return False
|
| 290 |
+
parsed = urlparse(configured)
|
| 291 |
+
if parsed.scheme not in {"postgres", "postgresql", "postgresql+asyncpg"}:
|
| 292 |
+
return False
|
| 293 |
+
hostname = (parsed.hostname or "").lower()
|
| 294 |
+
return bool(hostname and hostname not in {"localhost", "127.0.0.1", "::1"})
|
| 295 |
+
|
| 296 |
def ensure_directories(self) -> None:
|
| 297 |
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 298 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 388 |
or (parsed.scheme == "http" and parsed.hostname in local_hosts)
|
| 389 |
)
|
| 390 |
):
|
| 391 |
+
raise ValueError("X_REDIRECT_URI must be the HTTPS MediaRouter X callback URI")
|
|
|
|
|
|
|
| 392 |
return normalized
|
| 393 |
|
| 394 |
@field_validator("linkedin_redirect_uri")
|
app/core/database_url.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def normalize_async_database_url(value: str) -> str:
|
| 5 |
+
"""Use the installed asyncpg dialect for bare PostgreSQL URLs.
|
| 6 |
+
|
| 7 |
+
Operators frequently supply a standard ``postgresql://`` URL. SQLAlchemy
|
| 8 |
+
otherwise selects synchronous psycopg2 for that form, which fails inside
|
| 9 |
+
this async application and can tempt deployments to add an unnecessary
|
| 10 |
+
synchronous driver. Explicit dialect URLs remain unchanged.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
normalized = value.strip()
|
| 14 |
+
if normalized.startswith("postgresql://"):
|
| 15 |
+
return "postgresql+asyncpg://" + normalized.removeprefix("postgresql://")
|
| 16 |
+
if normalized.startswith("postgres://"):
|
| 17 |
+
return "postgresql+asyncpg://" + normalized.removeprefix("postgres://")
|
| 18 |
+
return normalized
|
app/generation/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider-neutral, durable generation orchestration.
|
| 2 |
+
|
| 3 |
+
This package contains provider-neutral generation orchestration plus audited,
|
| 4 |
+
optional WAN and FLUX adapters.
|
| 5 |
+
"""
|
app/generation/domain/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generation-domain value types, capabilities, and errors."""
|
| 2 |
+
|
| 3 |
+
from app.generation.domain.enums import (
|
| 4 |
+
GenerationJobStatus,
|
| 5 |
+
GenerationModality,
|
| 6 |
+
GenerationRequestStatus,
|
| 7 |
+
WorkerCancellationStatus,
|
| 8 |
+
WorkerErrorCategory,
|
| 9 |
+
WorkerHealthStatus,
|
| 10 |
+
WorkerJobStatus,
|
| 11 |
+
WorkerReadinessStatus,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"GenerationJobStatus",
|
| 16 |
+
"GenerationModality",
|
| 17 |
+
"GenerationRequestStatus",
|
| 18 |
+
"WorkerCancellationStatus",
|
| 19 |
+
"WorkerErrorCategory",
|
| 20 |
+
"WorkerHealthStatus",
|
| 21 |
+
"WorkerJobStatus",
|
| 22 |
+
"WorkerReadinessStatus",
|
| 23 |
+
]
|
app/generation/domain/capabilities.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 4 |
+
|
| 5 |
+
from app.generation.domain.enums import GenerationModality
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class GenerationModelCapability(BaseModel):
|
| 9 |
+
"""An explicitly implemented model contract.
|
| 10 |
+
|
| 11 |
+
``input_schema`` is descriptive only; request validation remains owned by
|
| 12 |
+
the adapter's typed Pydantic input model. It lets future transports build
|
| 13 |
+
capability-driven UIs without accepting arbitrary provider payloads.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
model_config = ConfigDict(extra="forbid")
|
| 17 |
+
|
| 18 |
+
id: str = Field(min_length=1, max_length=255)
|
| 19 |
+
name: str = Field(min_length=1, max_length=255)
|
| 20 |
+
modality: GenerationModality
|
| 21 |
+
input_asset_supported: bool = False
|
| 22 |
+
input_schema: dict[str, object] = Field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class GenerationProviderCapabilities(BaseModel):
|
| 26 |
+
"""Public, non-secret provider discovery metadata."""
|
| 27 |
+
|
| 28 |
+
model_config = ConfigDict(extra="forbid")
|
| 29 |
+
|
| 30 |
+
provider: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
|
| 31 |
+
name: str = Field(min_length=1, max_length=255)
|
| 32 |
+
models: list[GenerationModelCapability] = Field(default_factory=list)
|
| 33 |
+
implementation_status: str = Field(default="foundation", max_length=64)
|
| 34 |
+
supports_cancellation: bool = False
|
| 35 |
+
supports_status_reconciliation: bool = False
|
app/generation/domain/enums.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.core.enums import StrEnum
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class GenerationModality(StrEnum):
|
| 7 |
+
"""Output modalities supported by the foundation.
|
| 8 |
+
|
| 9 |
+
The list is intentionally small. An adapter may only advertise a value
|
| 10 |
+
after it implements that modality end to end.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
IMAGE = "image"
|
| 14 |
+
VIDEO = "video"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class GenerationRequestStatus(StrEnum):
|
| 18 |
+
QUEUED = "queued"
|
| 19 |
+
SUBMITTING = "submitting"
|
| 20 |
+
RUNNING = "running"
|
| 21 |
+
RETRYING = "retrying"
|
| 22 |
+
SUCCEEDED = "succeeded"
|
| 23 |
+
FAILED = "failed"
|
| 24 |
+
CANCEL_REQUESTED = "cancel_requested"
|
| 25 |
+
CANCELLED = "cancelled"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class GenerationJobStatus(StrEnum):
|
| 29 |
+
QUEUED = "queued"
|
| 30 |
+
SUBMITTING = "submitting"
|
| 31 |
+
RUNNING = "running"
|
| 32 |
+
RETRYING = "retrying"
|
| 33 |
+
SUCCEEDED = "succeeded"
|
| 34 |
+
FAILED = "failed"
|
| 35 |
+
CANCEL_REQUESTED = "cancel_requested"
|
| 36 |
+
CANCELLED = "cancelled"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class WorkerHealthStatus(StrEnum):
|
| 40 |
+
HEALTHY = "healthy"
|
| 41 |
+
STARTING = "starting"
|
| 42 |
+
UNAVAILABLE = "unavailable"
|
| 43 |
+
UNHEALTHY = "unhealthy"
|
| 44 |
+
UNKNOWN = "unknown"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class WorkerReadinessStatus(StrEnum):
|
| 48 |
+
READY = "ready"
|
| 49 |
+
STARTING = "starting"
|
| 50 |
+
UNAVAILABLE = "unavailable"
|
| 51 |
+
UNKNOWN = "unknown"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class WorkerJobStatus(StrEnum):
|
| 55 |
+
QUEUED = "queued"
|
| 56 |
+
RUNNING = "running"
|
| 57 |
+
COMPLETED = "completed"
|
| 58 |
+
FAILED = "failed"
|
| 59 |
+
CANCELLED = "cancelled"
|
| 60 |
+
UNKNOWN = "unknown"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class WorkerCancellationStatus(StrEnum):
|
| 64 |
+
REQUESTED = "requested"
|
| 65 |
+
CANCELLED = "cancelled"
|
| 66 |
+
UNSUPPORTED = "unsupported"
|
| 67 |
+
FAILED = "failed"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class WorkerErrorCategory(StrEnum):
|
| 71 |
+
INVALID_REQUEST = "invalid_request"
|
| 72 |
+
AUTHENTICATION_ERROR = "authentication_error"
|
| 73 |
+
AUTHORIZATION_ERROR = "authorization_error"
|
| 74 |
+
WORKER_UNAVAILABLE = "worker_unavailable"
|
| 75 |
+
WORKER_NOT_READY = "worker_not_ready"
|
| 76 |
+
TIMEOUT = "timeout"
|
| 77 |
+
RATE_LIMITED = "rate_limited"
|
| 78 |
+
PROVIDER_ERROR = "provider_error"
|
| 79 |
+
INFERENCE_ERROR = "inference_error"
|
| 80 |
+
OUTPUT_ERROR = "output_error"
|
| 81 |
+
CANCELLATION_ERROR = "cancellation_error"
|
| 82 |
+
UNKNOWN_ERROR = "unknown_error"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
TERMINAL_GENERATION_JOB_STATUSES = frozenset(
|
| 86 |
+
{
|
| 87 |
+
GenerationJobStatus.SUCCEEDED,
|
| 88 |
+
GenerationJobStatus.FAILED,
|
| 89 |
+
GenerationJobStatus.CANCELLED,
|
| 90 |
+
}
|
| 91 |
+
)
|