Prima Get an API key

Module kit / Media, audio and design

Brand Asset Resolution for Generated Designs

media-audio-design/brand-asset-resolution-for-designs

Kind backend
Domain media-audio-design
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping
Frontends media-audio-design/ui/picasso-design-gallery
Reference implementation
  • alchemy_labs/backend/api/routers/design_router.py
  • alchemy_labs/backend/api/routers/prima_design_router.py
  • alchemy_labs/backend/models/brand_essence_model.py
  • alchemy_labs/backend/schemas/design_schemas.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

Before a user can generate a social-media graphic, flyer or poster with their own brand's look, something has to turn their free-form Brand Essence record into a flat, predictable shape a design tool can actually consume: a colors object with four named arrays, a typography object with three named font strings, and a logo_url. _get_brand_assets (design_router.py 586-655, verbatim again at prima_design_router.py 279-348) does that resolution: it loads the user's latest BrandEssence row, tolerates every field arriving as either a bare string or a {hex: ...}/{font: ...}-shaped dict (the Brand Essence intake form and voice-memo pipeline do not guarantee one over the other), and falls back to a hardcoded blue palette (#3B82F6 primary, Inter typography) when the user has no Brand Essence row at all. The invariant that makes it trustworthy is that the shape returned is identical whether the source data is clean or malformed and whether a Brand Essence exists or not, so every caller (the Picasso manual editor's brand panel, Prima's chat-driven design generator) can read colors.primary[0], typography.headings, logo_url without a null check for the field's own presence, only for whether the list is empty.

When to use

Read it before touching _get_brand_assets in either router, before changing what BrandAssetsResponse/BrandColorsResponse/BrandTypographyResponse (schemas/design_schemas.py 335-356) promise, or when someone asks "why are the brand colors wrong in the design panel", "why did the logo not show up", "the font shows as an object not a string", or "a new user's designs are all blue and Inter". Trigger phrases: "brand assets", "brand-assets endpoint", "_get_brand_assets", "BrandEssence to design", "colors.primary", "typography.headings", "font shape", "logo_url fallback".

Not this recipe: POST /api/brand-essence/auto-save, GET /api/brand-essence/{project_id}/load, and the rest of brand_essence_router.py (mounted as tags=["Brand Essence"], code-commented "Brand Essence questionnaire (legacy)" at app_fastapi.py 872), which is where a BrandEssence row is actually written, this recipe only reads the latest one; DesignService/DesignTemplateService (services/design/), the design canvas CRUD that _get_brand_assets's caller sits beside, not part of; PrimaDesignService.chat/stream_chat (services/prima/design_service.py), which consumes the normalized dict this recipe produces (via _build_brand_context, 319-342) but is a separate recipe for the AI generation flow itself; useBrandAssets/normalizeBrandAssets in the frontend (frontend/src/components/picasso/hooks/useBrandAssets.ts 140-178), a THIRD, independent copy of the same string-or-dict tolerance logic, written in TypeScript against the already-normalized API response, cited under Gotchas, not owned by this recipe.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for the response schemas (imported but, see Gotchas, not actually applied to the response). No _shared foundation is dedicated to this pattern; S4 (fastapi-project-layout) is assumed for the app and mount_router, S5 (auth-dependency-pattern) for get_current_user, S6 (workspace-scoping) for the WorkspaceID dependency design_router.py's route declares (though, see Security & tenancy, the helper itself never uses it). No dedicated environment variable gates this code; it runs whenever a request reaches either router. Fail behaviour: neither _get_brand_assets copy catches anything itself, a db.query exception propagates to FastAPI's default 500 handler; the function's own resilience is only against malformed data (wrong-shaped colors/typography), not against a database error.

Data model

Reads one row, never writes: brand_essence (models/brand_essence_model.py 15-115), SELECT ... WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 1 (design_router.py 593-595 and prima_design_router.py 286-288, db.query(BrandEssence).filter(BrandEssence.user_id == user.id).order_by(desc(BrandEssence.created_at)).first()). The columns this recipe actually reads, via BrandEssence.to_dict()'s brand_identity.visual sub-object (brand_essence_model.py 179-186): primary_colors, secondary_colors (both JSONB, default=list, no accent_colors/neutral_colors column exists on the model, see Gotchas), logo_url (String(500)), typography (JSONB, default=dict). Every other field on BrandEssence (the StoryBrand character/problem/guide/plan columns, brand_voice_description, key_messages, etc.) is loaded by the ORM query but discarded by this helper, not stored or forwarded. Nothing is written by this recipe: no new table, no migration, no cache.

API surface

RouteAuthTenancyCodesNotes
GET /api/designs/brand-assets (design_router.py 325-337, get_brand_assets)Depends(get_current_user)workspace_id: WorkspaceID is a required query/header-derived dependency on the route, but the handler passes only db and current_user into _get_brand_assets; the lookup is user-scoped, not workspace-scoped (Security & tenancy)200 with the normalized dict, no response_model declared (Gotchas); 401 if get_current_user failsCalled from the frontend as GET /api/designs/brand-assets via getBrandAssets() (frontend/src/services/picassoService.ts 342-345)
POST /api/prima/design/chat and POST /api/prima/design/stream (prima_design_router.py 30-63, 104-159)Depends(get_current_user)No WorkspaceID dependency at all; workspace_id for the template lookup on the same request comes from _get_user_workspace(current_user) (270-276), which is a literal placeholder, return uuid4(), unrelated to _get_brand_assets's own user-only lookup (Gotchas)200 PrimaDesignChatResponse / SSE stream; 400 on ValueError from the design service; 500 on any other exception (both routes wrap the call in try/except, prima_design_router.py 91-101, 143-149)_get_brand_assets is called first, unconditionally, before templates are fetched (42, 116), its dict is threaded into PrimaDesignService.chat/stream_chat as the brand_assets kwarg

The one function this recipe is actually about, present twice, byte-for-byte identical (diffed 2026-09-12, diff between the two 586-655 and 279-348 ranges after stripping the surrounding route code is empty):

_get_brand_assets(db: Session, user: User) -> Dict[str, Any] (design_router.py 586-655; prima_design_router.py 279-348).

Application logic

In commit order, identical in both copies:

  1. Query the latest BrandEssence row for user.id, newest created_at first (design_router.py 593-595).
  2. If a row exists: call .to_dict(), descend to brand_identity.visual (brand_dict.get("brand_identity", {}).get("visual", {}), 599), read typography off it defaulting to {} when the stored value is None (visual.get("typography") or {}, 600, guards the JSONB column's own nullability separately from key-absence).
  3. _font_name(val) (602-608): a plain str passes through unchanged; a dict is probed for font, then family, then name, first truthy wins, else None; any other type (including None) returns None. Applied to typography.headings, typography.body, typography.accent independently (629-632).
  4. _color_list(val) (610-620): a non-list input returns [] outright (no partial tolerance for e.g. a single string); each list item that is a str is kept as-is, each dict item is probed for hex, then value, falling back to str(c) (a dict's Python repr, not None, if neither key exists); any other item type is silently dropped (it fails both isinstance checks in the loop and contributes nothing to result). Applied to visual.get("primary_colors", []) and visual.get("secondary_colors", []) (624-625); visual.get("accent_colors", []) and visual.get("neutral_colors", []) are also read (626-627) but, since no such columns exist on BrandEssence (Data model), always resolve to visual.get(..., [])'s default [] in practice for a real row.
  5. Assemble and return {"colors": {"primary": ..., "secondary": ..., "accent": ..., "neutral": ...}, "typography": {"headings": ..., "body": ..., "accent": ...}, "logo_url": visual.get("logo_url"), "logo_mark_url": visual.get("logo_mark_url"), "favicon_url": visual.get("favicon_url")} (622-637); the last two keys are also columns that do not exist on the model, so they resolve to None via .get()'s default for every real row today.
  6. If no row exists at all: return the hardcoded fallback (640-655), same shape, primary: ["#3B82F6"], secondary: ["#6B7280"], accent: ["#10B981"], neutral: ["#F3F4F6", "#9CA3AF", "#1F2937"], typography.headings = typography.body = "Inter", typography.accent = None, all three URL fields None.

Invariants

  • The returned dict's shape (five top-level keys, four color arrays, three typography strings) is identical whether a BrandEssence row exists or not, and whether its typography/color fields are strings, dicts, or absent (steps 3-6 above; no test exercises this directly, see Tests).
  • _color_list never raises on a malformed item; a dict without hex or value degrades to that dict's str() rather than being dropped or raising (610-620), a design consuming colors.primary[0] as a CSS color string can receive something like "{'unexpected_key': 'x'}" without either function complaining.
  • _font_name returns None, never raises, for anything that is not a str or a dict with one of the three known keys (602-608); nothing downstream (_build_brand_context, design_schemas.BrandTypographyResponse.headings: Optional[str]) treats that None as an error.
  • The fallback palette is returned only on "no row" (if brand_essence: / implicit else, 597 vs. 640); it is never blended with a partial real row, a user with a BrandEssence row that has empty primary_colors gets "primary": [] from step 4, not ["#3B82F6"].

Frontend

media-audio-design/ui/picasso-design-gallery   PicassoGallery.tsx, PicassoEditor.tsx and
                                                 PicassoManualEditor.tsx (frontend/src/pages and
                                                 frontend/src/components/picasso/) are the real
                                                 consumers; BrandAssetsPanel.tsx (219 lines) renders
                                                 the colors/fonts/logo this recipe returns as
                                                 clickable swatches (onColorClick/onFontClick/
                                                 onLogoClick props, BrandAssetsPanel.tsx 11-18);
                                                 useBrandAssets.ts (180 lines) is the hook that
                                                 calls getBrandAssets() and holds the result in
                                                 state

Minimum admin/debug surface: none exists at this commit; there is no admin route that shows a resolved brand-assets payload independent of the two live callers. SELECT id, user_id, primary_colors, secondary_colors, typography, logo_url FROM brand_essence WHERE user_id = :id ORDER BY created_at DESC LIMIT 1 is the query a human runs by hand to see what row this recipe will resolve next.

Security & tenancy

Auth dependency: Depends(get_current_user) on both routes (JWT bearer, httpOnly cookie, or X-API-Key, priority in that order per api/dependencies.py 187-202), same as every other authenticated Labs route; neither _get_brand_assets copy takes or checks a workspace id itself. Tenancy: the lookup is BrandEssence.user_id == user.id only (brand_essence_model.py 23, user_id is unique=True, one essence per user, not per workspace). On GET /designs/brand-assets, workspace_id: WorkspaceID is declared as a route parameter and FastAPI resolves and access-checks it (_get_workspace_id, api/dependencies.py 680-698) before the handler body runs at all, but the resolved value is never passed into _get_brand_assets or used to filter anything (design_router.py 336, brand_assets = await _get_brand_assets(db, current_user)), so the dependency's only live effect here is refusing the request for a user with zero accessible workspaces or an invalid X-Workspace-ID header, not scoping which brand assets come back. On the /prima/design/chat and /prima/design/stream routes there is no WorkspaceID dependency at all; the only workspace-shaped value on those requests, _get_user_workspace(current_user), is a return uuid4() placeholder (270-276) used solely for the sibling templates lookup, not for brand assets. Because the essence is resolved from user.id alone, the practical exposure is user-level, not workspace-level: a user who belongs to multiple workspaces sees the same brand palette in every one of them, by construction, not by a bug in this helper. Fail direction: open, not closed, an absent or malformed BrandEssence never raises or 4xxs, it silently returns the hardcoded blue-and-Inter fallback (640-655); there is no way for this endpoint alone to tell a caller "brand assets are missing" versus "brand assets are the default blue palette". Secrets/PII: none, logo_url is a URL string, no image bytes or credentials pass through this function. Rate limits: none dedicated; it rides whatever limits, if any, sit in front of get_current_user.

Tests

No dedicated test file exists for _get_brand_assets, GET /designs/brand-assets, or /prima/design/chat//stream at this commit: grep -rl "_get_brand_assets\|brand-assets" backend/tests/ (repo root alchemy_labs/backend/tests/) returns nothing, and the checked-in coverage report (backend/htmlcov/z_1a87e07b7d9ec38e_design_router_py.html, z_1a87e07b7d9ec38e_prima_design_router_py.html) shows 0% line coverage for both router files (measured 2026-09-12; htmlcov/ is gitignored, not evidence of a run against this exact commit, only the most recent local coverage artifact present). This is a genuine gap, not a hermetic-vs-live distinction to note and move past: a rebuild of this recipe has no known-answer vector to check itself against beyond reading the source directly, as this recipe's own Application logic section does.

Deploy & ops

Config keys: none specific to this recipe (no env var gates it). Migrations: none owned by this recipe; the brand_essence table and its columns predate this commit and are not touched here (no migration under backend/migrations/versions/ references accent_colors, neutral_colors, logo_mark_url, or favicon_url, the four keys this code reads that do not exist as columns, confirmed by grep -n "accent_colors\|neutral_colors\|logo_mark_url\|favicon_url" backend/models/brand_essence_model.py returning nothing). Scheduler: none, both call sites are synchronous request handlers. Rollback: nothing stateful to unwind, this recipe reads only.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, design_router.py 586-655 vs. prima_design_router.py 279-348): the entire _get_brand_assets function, both helper closures and both return branches, is duplicated verbatim between the two routers. A fix to one (say, adding the missing accent_colors/neutral_colors columns) silently does not apply to the other unless both are edited; nothing enforces they stay in sync.
  • 2026-09-12 (measured at 62ba4fc, frontend/src/components/picasso/hooks/useBrandAssets.ts 140-178): the frontend has its own third copy of the same string-or-dict tolerance logic (normalizeFont, normalizeColorList), applied to the already-normalized API response. The backend's _font_name/_color_list already guarantee strings; the frontend re-normalizes anyway, which is harmless today (normalizing a string returns the string unchanged) but means a future backend-only fix to the normalization rules (e.g. preferring a different dict key) has no visible effect until the frontend copy is updated to match, and there is no shared source of truth for the rule "try hex, then value".
  • 2026-09-12 (measured at 62ba4fc, design_router.py 33, 325-337): BrandAssetsResponse (and BrandColorsResponse/BrandTypographyResponse) is imported into design_router.py and defined in schemas/design_schemas.py 335-356, but get_brand_assets declares no response_model and returns the raw dict from _get_brand_assets directly. The schema exists, matches the dict's shape by inspection, but enforces nothing at request time; a future change to _get_brand_assets's return shape would not be caught by FastAPI/Pydantic validation, only by a caller noticing missing keys.
  • 2026-09-12 (measured at 62ba4fc, models/brand_essence_model.py 82-86 vs. design_router.py 626-627, 635-636): _get_brand_assets reads visual.get("accent_colors", []), visual.get("neutral_colors", []), visual.get("logo_mark_url"), visual.get("favicon_url"), none of which are columns on BrandEssence (only primary_colors, secondary_colors, logo_url, typography exist, see Data model). For every real row today these four always resolve to their .get() defaults; the code reads as if the model has four fields it does not have.
  • 2026-09-12 (measured at 62ba4fc, prima_design_router.py 270-276): _get_user_workspace(current_user) is a stub, return uuid4(), called from both /chat and /stream to scope the sibling template lookup (not brand assets). Cited here because it sits directly beside the _get_brand_assets call in both routes and reads as if workspace scoping is happening on this seam when it is not, for either the templates or the brand assets.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/api/routers/design_router.py (655 lines): imports and BrandAssetsResponse import (1-34); GET /designs/brand-assets route (325-337); _get_brand_assets (586-655).
  • alchemy_labs/backend/api/routers/prima_design_router.py (349 lines, whole file in scope): POST /prima/design/chat (30-101); POST /prima/design/stream (104-159); _get_user_workspace stub (270-276); _get_brand_assets (279-348).
  • alchemy_labs/backend/models/brand_essence_model.py (207 lines, whole file in scope): BrandEssence model (15-115, visual identity columns 81-87); to_dict (117-206, brand_identity.visual sub-object 179-186).
  • alchemy_labs/backend/schemas/design_schemas.py: BrandColorsResponse/BrandTypographyResponse/BrandAssetsResponse (335-356).
  • alchemy_labs/backend/services/prima/design_service.py: _build_brand_context, the consumer that turns the resolved dict into the German-language prompt context Prima's design chat reads (319-342).
  • alchemy_labs/backend/api/dependencies.py: get_current_user (187-267); _get_workspace_id/WorkspaceID (680-779).
  • alchemy_labs/backend/api/routers/brand_essence_router.py: the neighbouring write-side CRUD, out of scope (routes listed under When to use).
  • alchemy_labs/frontend/src/services/picassoService.ts: getBrandAssets (342-345), BrandAssets TS interface (67-82).
  • alchemy_labs/frontend/src/components/picasso/hooks/useBrandAssets.ts (180 lines, whole file in scope): the hook (16-111); getDefaultBrandAssets (116-133); normalizeBrandAssets/normalizeFont/normalizeColorList (140-178).
  • alchemy_labs/frontend/src/components/picasso/Panels/BrandAssetsPanel.tsx (219 lines): the panel that renders the resolved assets.
  • alchemy_labs/frontend/src/pages/PicassoGallery.tsx, alchemy_labs/frontend/src/components/picasso/PicassoEditor.tsx, PicassoManualEditor.tsx: the real UI surfaces that reach this endpoint, cited for frontend_modules.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the normalization logic exists in three independent copies (two backend, one frontend) rather than one shared function; BrandAssetsResponse is defined but not wired to the route it names; four of the five keys the helper reads from visual (accent_colors, neutral_colors, logo_mark_url, favicon_url) have no backing column; _get_user_workspace beside the brand-assets call is an unimplemented placeholder; no test file covers any of this.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe); no live database is required for checks 1-4, they inspect source only.

  1. grep -n "async def _get_brand_assets" api/routers/design_router.py api/routers/prima_design_router.py prints one match per file, confirming the duplicate exists at both call sites named in this recipe.
  2. diff <(sed -n '586,655p' api/routers/design_router.py) <(sed -n '279,348p' api/routers/prima_design_router.py) prints no output (the two functions are byte-for-byte identical).
  3. python3 -c "from api.routers.design_router import _get_brand_assets; import asyncio; from unittest.mock import MagicMock; db = MagicMock(); db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None; user = MagicMock(); print(asyncio.run(_get_brand_assets(db, user)))" prints the fallback dict with 'primary': ['#3B82F6'] and 'headings': 'Inter', confirming the no-row branch (this is a smoke check, not a fixture-backed test; no such fixture exists in the repo, see Tests).
  4. grep -n "accent_colors\|neutral_colors\|logo_mark_url\|favicon_url" models/brand_essence_model.py prints no output, confirming those four keys read by _get_brand_assets have no backing column at this commit.
  5. grep -n "response_model" api/routers/design_router.py | grep -i brand prints no output, confirming GET /designs/brand-assets declares no response model despite BrandAssetsResponse being imported.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.