basyx commited on
Commit
3493993
·
verified ·
1 Parent(s): a44271f

Upload 340 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +70 -0
  2. Dockerfile +3 -1
  3. README.md +69 -7
  4. app/ai/__init__.py +1 -0
  5. app/ai/api.py +87 -0
  6. app/ai/schemas.py +171 -0
  7. app/ai/service.py +378 -0
  8. app/analytics/__init__.py +1 -0
  9. app/analytics/api.py +152 -0
  10. app/analytics/errors.py +18 -0
  11. app/analytics/models.py +178 -0
  12. app/analytics/repository.py +385 -0
  13. app/analytics/schemas.py +140 -0
  14. app/analytics/service.py +379 -0
  15. app/analytics/workers/__init__.py +1 -0
  16. app/analytics/workers/sync.py +99 -0
  17. app/api/api_keys.py +7 -1
  18. app/api/generation.py +118 -0
  19. app/api/media.py +21 -1
  20. app/api/social.py +239 -41
  21. app/brand/__init__.py +2 -0
  22. app/brand/api.py +99 -0
  23. app/brand/capabilities.py +26 -0
  24. app/brand/errors.py +9 -0
  25. app/brand/models.py +165 -0
  26. app/brand/models/__init__.py +3 -0
  27. app/brand/models/brand.py +64 -0
  28. app/brand/repositories/brand_repository.py +138 -0
  29. app/brand/repository.py +603 -0
  30. app/brand/schemas.py +20 -0
  31. app/brand/service.py +636 -0
  32. app/brand/services/brand_service.py +64 -0
  33. app/brand/services/validation_service.py +29 -0
  34. app/brand/validation.py +30 -0
  35. app/container.py +236 -13
  36. app/copilot/__init__.py +1 -0
  37. app/copilot/actions.py +853 -0
  38. app/copilot/api.py +121 -0
  39. app/copilot/errors.py +36 -0
  40. app/copilot/models.py +66 -0
  41. app/copilot/planner.py +627 -0
  42. app/copilot/repository.py +196 -0
  43. app/copilot/schemas.py +439 -0
  44. app/copilot/service.py +499 -0
  45. app/core/config.py +160 -19
  46. app/core/database_url.py +18 -0
  47. app/generation/__init__.py +5 -0
  48. app/generation/domain/__init__.py +23 -0
  49. app/generation/domain/capabilities.py +35 -0
  50. 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. 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.
118
- 4. Wait for the Docker build. The first Whisper call downloads the selected model to the Hugging Face cache. Persistent storage is optional, but avoids downloading models again after a cold rebuild.
119
- 5. Check the public `https://<owner>-<space>.hf.space/health`, then use the saved key for every `/v1/*` or `/mcp/` request.
 
 
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
- For durable keys, audit records, and rate aggregates, attach Hugging Face persistent storage and set `DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db`. The default `./data/mediarouter.db` is appropriate locally but follows the Space filesystem lifecycle. The security layer uses SQLAlchemy so a future external database migration does not change authentication contracts; this release includes and supports the `aiosqlite` driver.
 
 
 
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=sqlite+aiosqlite:////data/mediarouter.db
 
 
 
 
 
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
- `DATABASE_URL` uses `/data` so keys, audit logs, and rate-limit state survive only when Hugging Face persistent storage is attached. `BASE_URL` is the public Hugging Face origin, not the Vercel frontend URL.
 
 
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` | Hash-only keys, audit records, and rate aggregates |
 
 
 
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, created_by=context.api_key_id
 
 
 
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
- path = container.cleanup.resolve_download(request_id, filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.posts import SocialPostCreate, SocialPostView
21
- from app.social.schemas.scheduling import SocialScheduleCreate, SocialScheduleView
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Phase 1 isolation: the authenticated API-key identity is the tenant
35
- # boundary until the backend gains a native workspace membership model.
36
- return context.api_key_id, context.api_key_id
 
 
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, user_id = _identity(request)
49
  await request.app.state.container.social.audit.record(
50
  workspace_id=workspace_id,
51
  event_type=event_type,
52
- api_key_id=user_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, offset=offset, limit=limit
 
 
 
 
 
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
- schedule = await _social(request).scheduling.schedule(
276
- workspace_id, post_id, payload
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(settings.database_url)
67
- api_keys = APIKeyService(security_database, settings)
 
 
 
 
68
  rate_limiter = APIKeyRateLimiter(security_database)
69
  audit = AuditService(security_database)
70
- # Social publishing validates the same file-backed outputs as the normal
71
- # media pipeline, so build those shared services before wiring Social.
 
 
 
 
72
  cleanup = CleanupService(settings)
 
 
 
73
  validator = MediaValidator(settings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  downloader = Downloader(settings, validator)
75
  ytdlp = YTDLPService(settings, validator)
76
  ffmpeg = FFmpegService(settings)
77
- ffprobe = FFprobeService(settings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=PublishingService(
112
- settings, post_repository, job_repository, account_repository, providers,
113
- social_media_assets, oauth_service,
 
 
 
 
 
 
 
114
  ),
115
- scheduling=SchedulingService(post_repository, social_media_assets),
116
  jobs=JobService(job_repository),
117
  media_assets=social_media_assets,
118
- analytics=AnalyticsService(social_database, account_repository, providers, oauth_service),
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
- default=60.0, gt=0, le=600
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
+ )