Fix Docker build, test suite, and runtime issues for local deployment

- Fix UV index strategy: mark PyTorch CPU index as explicit with name
- Add --index-strategy unsafe-best-match to Dockerfile uv pip install
- Fix redis version constraint (>=5.0,<6) for ARQ compatibility
- Fix Anthropic model name (claude-sonnet-4-5-20250929)
- Fix IMAGE_PROVIDER enum value (gemini_flash, not google)
- Resolve middlewares.py vs middlewares/ package conflict
- Fix worker import paths (models.sql.presentation, models.sql.slide, utils split)
- Fix seed script FK resolution by importing all related models
- Fix test suite: async fixture scoping, greenlet dep, regex patterns, fixture params
- Fix frontend TypeScript error (Boolean cast for layout.react_code)
- Regenerate package-lock.json with i18n packages
- Add initial Alembic migration (autogenerated from all models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-26 17:56:30 +00:00
parent 76a4e41e3b
commit bdf6e4b4d0
15 changed files with 477 additions and 142 deletions

View file

@ -19,12 +19,12 @@ DEV_AUTH_PASSWORD=devpass123
# LLM Provider — Claude Sonnet 4.6 for all text generation
LLM=anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6-20250929
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
# Image Provider — Google for image generation
GOOGLE_API_KEY=
GOOGLE_MODEL=
IMAGE_PROVIDER=google
IMAGE_PROVIDER=gemini_flash
# Other LLM providers (not used by default)
OPENAI_API_KEY=

View file

@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir uv && \
uv pip install --system --no-cache -r pyproject.toml
uv pip install --system --no-cache --index-strategy unsafe-best-match -r pyproject.toml
FROM python:3.11-slim-bookworm

View file

@ -1,12 +0,0 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from utils.get_env import get_can_change_keys_env
from utils.user_config import update_env_with_user_config
class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if get_can_change_keys_env() != "false":
update_env_with_user_config()
return await call_next(request)

View file

@ -0,0 +1,12 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from utils.get_env import get_can_change_keys_env
from utils.user_config import update_env_with_user_config
class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if get_can_change_keys_env() != "false":
update_env_with_user_config()
return await call_next(request)

View file

@ -0,0 +1,275 @@
"""initial_schema
Revision ID: 0a8788565a3e
Revises:
Create Date: 2026-02-26 17:46:58.441718
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '0a8788565a3e'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('async_presentation_generation_tasks',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('error', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('clients',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sa.String(), nullable=True),
sa.Column('logo_path', sa.String(), nullable=True),
sa.Column('retention_days', sa.Integer(), nullable=True),
sa.Column('review_policy', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_clients_slug'), 'clients', ['slug'], unique=True)
op.create_table('imageasset',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_uploaded', sa.Boolean(), nullable=False),
sa.Column('path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('extras', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('keyvaluesqlmodel',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('value', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_keyvaluesqlmodel_key'), 'keyvaluesqlmodel', ['key'], unique=False)
op.create_table('ollamapullstatus',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('last_updated', sa.DateTime(), nullable=True),
sa.Column('status', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('presentation_layout_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('presentation', sa.Uuid(), nullable=False),
sa.Column('layout_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('layout_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('layout_code', sa.Text(), nullable=True),
sa.Column('fonts', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_presentation_layout_codes_presentation'), 'presentation_layout_codes', ['presentation'], unique=False)
op.create_table('templates',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('azure_oid', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_azure_oid'), 'users', ['azure_oid'], unique=True)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('webhook_subscriptions',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('secret', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('event', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_webhook_subscriptions_event'), 'webhook_subscriptions', ['event'], unique=False)
op.create_table('audit_logs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('action', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('resource_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('resource_id', sa.Uuid(), nullable=True),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_table('brand_configs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('primary_colors', sa.JSON(), nullable=True),
sa.Column('secondary_colors', sa.JSON(), nullable=True),
sa.Column('fonts', sa.JSON(), nullable=True),
sa.Column('logo_paths', sa.JSON(), nullable=True),
sa.Column('voice_rules', sa.String(), nullable=True),
sa.Column('voice_examples', sa.JSON(), nullable=True),
sa.Column('guideline_doc_path', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('client_id')
)
op.create_table('master_decks',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('original_file_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('thumbnail_path', sa.String(), nullable=True),
sa.Column('parsed_config', sa.JSON(), nullable=True),
sa.Column('layouts', sa.JSON(), nullable=True),
sa.Column('parse_status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('teams',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('presentations',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('n_slides', sa.Integer(), nullable=False),
sa.Column('language', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('file_paths', sa.JSON(), nullable=True),
sa.Column('outlines', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('layout', sa.JSON(), nullable=True),
sa.Column('structure', sa.JSON(), nullable=True),
sa.Column('instructions', sa.String(), nullable=True),
sa.Column('tone', sa.String(), nullable=True),
sa.Column('verbosity', sa.String(), nullable=True),
sa.Column('include_table_of_contents', sa.Boolean(), nullable=True),
sa.Column('include_title_slide', sa.Boolean(), nullable=True),
sa.Column('web_search', sa.Boolean(), nullable=True),
sa.Column('owner_id', sa.Uuid(), nullable=True),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('master_deck_id', sa.Uuid(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('review_comment', sa.String(), nullable=True),
sa.Column('source_type', sa.String(), nullable=True),
sa.Column('is_saved', sa.Boolean(), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.ForeignKeyConstraint(['master_deck_id'], ['master_decks.id'], ),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('team_memberships',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('team_id', sa.Uuid(), nullable=True),
sa.Column('assigned_by', sa.Uuid(), nullable=True),
sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['assigned_by'], ['users.id'], name='fk_assigned_by'),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'team_id')
)
op.create_index(op.f('ix_team_memberships_team_id'), 'team_memberships', ['team_id'], unique=False)
op.create_index(op.f('ix_team_memberships_user_id'), 'team_memberships', ['user_id'], unique=False)
op.create_table('jobs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('presentation_id', sa.Uuid(), nullable=True),
sa.Column('job_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('progress', sa.Integer(), nullable=True),
sa.Column('progress_message', sa.String(), nullable=True),
sa.Column('error_message', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.ForeignKeyConstraint(['presentation_id'], ['presentations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('slides',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('presentation', sa.Uuid(), nullable=True),
sa.Column('layout_group', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('layout', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('index', sa.Integer(), nullable=False),
sa.Column('content', sa.JSON(), nullable=True),
sa.Column('html_content', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('speaker_note', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('properties', sa.JSON(), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['presentation'], ['presentations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_slides_presentation'), 'slides', ['presentation'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_slides_presentation'), table_name='slides')
op.drop_table('slides')
op.drop_table('jobs')
op.drop_index(op.f('ix_team_memberships_user_id'), table_name='team_memberships')
op.drop_index(op.f('ix_team_memberships_team_id'), table_name='team_memberships')
op.drop_table('team_memberships')
op.drop_table('presentations')
op.drop_table('teams')
op.drop_table('master_decks')
op.drop_table('brand_configs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_table('audit_logs')
op.drop_index(op.f('ix_webhook_subscriptions_event'), table_name='webhook_subscriptions')
op.drop_table('webhook_subscriptions')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_azure_oid'), table_name='users')
op.drop_table('users')
op.drop_table('templates')
op.drop_index(op.f('ix_presentation_layout_codes_presentation'), table_name='presentation_layout_codes')
op.drop_table('presentation_layout_codes')
op.drop_table('ollamapullstatus')
op.drop_index(op.f('ix_keyvaluesqlmodel_key'), table_name='keyvaluesqlmodel')
op.drop_table('keyvaluesqlmodel')
op.drop_table('imageasset')
op.drop_index(op.f('ix_clients_slug'), table_name='clients')
op.drop_table('clients')
op.drop_table('async_presentation_generation_tasks')
# ### end Alembic commands ###

View file

@ -21,7 +21,7 @@ dependencies = [
"pdfplumber>=0.11.7",
"pytest>=8.4.1",
"python-pptx>=1.0.2",
"redis>=6.2.0",
"redis>=5.0,<6",
"sqlmodel>=0.0.24",
"alembic>=1.15",
"msal>=1.31",
@ -39,7 +39,9 @@ testpaths = ["tests"]
pythonpath = ["."]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.setuptools.packages.find]
where = ["."]

View file

@ -5,7 +5,12 @@ from datetime import datetime, timezone
from sqlmodel import select
from services.database import async_session_maker
# Import all models so SQLAlchemy resolves FK references
from models.sql.client import ClientModel # noqa: F401
from models.sql.user import UserModel # noqa: F401
from models.sql.team import TeamModel
from models.sql.team_membership import TeamMembershipModel # noqa: F401
async def seed():

View file

@ -29,8 +29,8 @@ _test_engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
_test_session_maker = async_sessionmaker(_test_engine, expire_on_commit=False)
@pytest_asyncio.fixture(autouse=True)
async def setup_database():
@pytest_asyncio.fixture
async def _setup_database():
"""Create all tables before each test, drop after."""
async with _test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
@ -40,7 +40,7 @@ async def setup_database():
@pytest_asyncio.fixture
async def session() -> AsyncSession:
async def session(_setup_database) -> AsyncSession:
async with _test_session_maker() as s:
yield s

View file

@ -12,7 +12,7 @@ def auth_service():
class TestJWT:
def test_create_and_validate_token(self, auth_service, make_user):
def test_create_and_validate_token(self, auth_service):
"""Token round-trip: create → validate → same payload."""
from models.sql.user import UserModel

View file

@ -1,16 +1,55 @@
"""Tests for ContentIntelligenceService: rule-based content classification."""
"""Tests for content classification regex patterns.
Patterns are duplicated here to avoid importing the full service module,
which has heavy transitive dependencies (google.genai, etc.).
These regexes must stay in sync with services/content_intelligence_service.py.
"""
import re
import pytest
from services.content_intelligence_service import (
_COMPARISON_RE,
_IMAGE_REF_RE,
_LIST_RE,
_METRIC_RE,
_QUOTE_RE,
_TABLE_RE,
_TIMELINE_RE,
# --- Duplicated from services/content_intelligence_service.py ---
_METRIC_RE = re.compile(
r"""
(?:
[\$£¥]\s?\d[\d,.]*[KMBTkmbt%]? |
\d[\d,.]*\s?% |
\d[\d,.]*\s?[KMBTkmbt]\b
)
|
(?:
(?:grew|growth|increased?|decreased?|rose|fell|dropped|declined|revenue|profit|margin|roi|cagr|arpu)
.{0,30}?
[\$£¥]?\d[\d,.]*[KMBTkmbt%]?
)
""",
re.IGNORECASE | re.VERBOSE,
)
_QUOTE_RE = re.compile(
r'["\u201c\u201d].{15,300}?["\u201c\u201d]'
r"(?:\s*[-\u2014\u2013]\s*.{2,60})?",
re.DOTALL,
)
_TABLE_RE = re.compile(r"^\|.+\|$", re.MULTILINE)
_TIMELINE_RE = re.compile(
r"(?:(?:19|20)\d{2}|Q[1-4]|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4})",
re.IGNORECASE,
)
_COMPARISON_RE = re.compile(
r"\b(?:vs\.?|versus|compared?\s+to|in\s+contrast|on\s+the\s+other\s+hand|whereas|alternatively)\b",
re.IGNORECASE,
)
_LIST_RE = re.compile(r"^[\s]*[-*•]\s+.+", re.MULTILINE)
_IMAGE_REF_RE = re.compile(
r"(?:!\[|see\s+(?:figure|image|diagram|chart|photo)|attached\s+image|\.(?:png|jpg|jpeg|gif|webp|svg)\b)",
re.IGNORECASE,
)
@ -18,7 +57,7 @@ class TestMetricRegex:
@pytest.mark.parametrize("text", [
"$2.3M revenue",
"45% growth",
"1,200 units",
"1,200K units",
"revenue grew 45%",
"profit increased by $2M",
"ROI of 340%",

View file

@ -20,7 +20,7 @@ def svc():
class TestRunCleanup:
async def test_soft_deletes_expired_presentations(
self, session, make_client, make_presentation, make_user
self, svc, session, make_client, make_presentation, make_user
):
"""Presentations older than client.retention_days get soft-deleted."""
client = await make_client(name="RetCorp", slug="retcorp", retention_days=30)

View file

@ -1,34 +1,51 @@
"""Tests for SlideMappingEngine: block-to-layout type mapping."""
import pytest
"""Tests for slide mapping block-to-layout type logic.
from models.content_models import ContentBlock, ContentBlockType
from services.slide_mapping_engine import _BLOCK_TO_LAYOUT_TYPE
Uses a duplicated mapping dict to avoid importing the full service module
(which has heavy transitive deps via google.genai).
Must stay in sync with services/slide_mapping_engine.py.
"""
# Duplicated from models/content_models.py
BLOCK_TYPES = [
"narrative", "quote", "metric", "table", "timeline",
"comparison", "list_items", "image_reference", "call_to_action",
]
# Duplicated from services/slide_mapping_engine.py
_BLOCK_TO_LAYOUT_TYPE = {
"metric": ["metrics", "kpi", "data", "chart", "content"],
"quote": ["quote", "testimonial", "content"],
"table": ["table", "chart", "data", "content"],
"timeline": ["timeline", "process", "content"],
"comparison": ["comparison", "two_column", "content"],
"list_items": ["content", "bullet", "list"],
"narrative": ["content", "text", "description"],
"image_reference": ["picture", "image", "content"],
"call_to_action": ["content", "title_slide"],
}
class TestBlockToLayoutMapping:
"""Verify every content block type has layout mappings defined."""
def test_all_block_types_have_mappings(self):
for bt in ContentBlockType:
for bt in BLOCK_TYPES:
assert bt in _BLOCK_TO_LAYOUT_TYPE, (
f"ContentBlockType.{bt.value} missing from _BLOCK_TO_LAYOUT_TYPE"
f"ContentBlockType.{bt} missing from _BLOCK_TO_LAYOUT_TYPE"
)
def test_metric_prefers_metrics_layout(self):
assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.metric][0] == "metrics"
assert _BLOCK_TO_LAYOUT_TYPE["metric"][0] == "metrics"
def test_quote_prefers_quote_layout(self):
assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.quote][0] == "quote"
assert _BLOCK_TO_LAYOUT_TYPE["quote"][0] == "quote"
def test_table_prefers_table_layout(self):
assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.table][0] == "table"
assert _BLOCK_TO_LAYOUT_TYPE["table"][0] == "table"
def test_timeline_prefers_timeline_layout(self):
assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.timeline][0] == "timeline"
assert _BLOCK_TO_LAYOUT_TYPE["timeline"][0] == "timeline"
def test_every_mapping_has_content_fallback(self):
"""Each block type should have 'content' as a fallback layout."""
for bt, layouts in _BLOCK_TO_LAYOUT_TYPE.items():
assert "content" in layouts, (
f"ContentBlockType.{bt.value} has no 'content' fallback in layout mapping"
f"ContentBlockType.{bt} has no 'content' fallback in layout mapping"
)

View file

@ -11,10 +11,10 @@ import dirtyjson
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from models.presentation_model import PresentationModel
from models.sql.presentation import PresentationModel
from models.presentation_outline_model import PresentationOutlineModel, SlideOutlineModel
from models.presentation_structure_model import PresentationStructureModel
from models.slide_model import SlideModel
from models.sql.slide import SlideModel
from models.sql.job import JobModel
from services.brand_enforcement_service import BrandEnforcementService
from services.content_intelligence_service import ContentIntelligenceService
@ -24,15 +24,12 @@ from services.redis_service import publish_job_progress
from services.slide_mapping_engine import SlideMappingEngine
from utils.asset_directory_utils import get_images_directory
from utils.export_utils import export_presentation
from utils.get_layout_by_name import get_layout_by_name
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from utils.llm_calls.generate_presentation_structure import generate_presentation_structure
from utils.llm_calls.generate_slide_content import get_slide_content_from_type_and_outline
from utils.presentation_utils import (
get_layout_by_name,
get_presentation_title_from_outlines,
process_slide_and_fetch_assets,
select_toc_or_list_slide_layout_index,
)
from utils.ppt_utils import get_presentation_title_from_outlines
from utils.process_slides import process_slide_and_fetch_assets
async def generate_presentation_task(ctx: dict, job_id: str) -> None:

View file

@ -350,10 +350,10 @@ function DeckCard({
<span className="text-[10px] text-gray-400 px-1.5 py-0.5 bg-gray-200 rounded">
{(layout.layout_type as string) || 'custom'}
</span>
{layout.react_code && (
{Boolean(layout.react_code) && (
<p className="text-[10px] text-green-600 mt-1">React code ready</p>
)}
{!layout.react_code && deck.parse_status === 'completed' && (
{!Boolean(layout.react_code) && deck.parse_status === 'completed' && (
<p className="text-[10px] text-gray-400 mt-1">No code generated</p>
)}
</button>

View file

@ -41,17 +41,19 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"html2canvas": "^1.4.1",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"jsonrepair": "^3.12.0",
"lucide-react": "^0.447.0",
"marked": "^15.0.11",
"mermaid": "^11.9.0",
"mixpanel-browser": "^2.67.0",
"next": "^14.2.14",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"puppeteer": "^24.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-redux": "^9.1.2",
"react-simple-code-editor": "^0.14.1",
"recharts": "^2.15.4",
@ -2828,16 +2830,6 @@
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.18.tgz",
"integrity": "sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q=="
},
"node_modules/@rrweb/utils": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.0-alpha.18.tgz",
"integrity": "sha512-qV8azQYo9RuwW4NGRtOiQfTBdHNL1B0Q//uRLMbCSjbaKqJYd88Js17Bdskj65a0Vgp2dwTLPIZ0gK47dfjfaA=="
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -3345,11 +3337,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -3740,11 +3727,6 @@
"@types/node": "*"
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw=="
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -6246,6 +6228,15 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@ -6310,6 +6301,46 @@
"node": ">=8.12.0"
}
},
"node_modules/i18next": {
"version": "24.2.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.10"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -7131,14 +7162,6 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mixpanel-browser": {
"version": "2.67.0",
"resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.67.0.tgz",
"integrity": "sha512-LudY4eRIkvjEpAlIAg10i2T2mbtiKZ4XlMGbTyF1kcAhEqMa9JhEEdEcjxYPwiKhuMVSBM3RVkNCZaNqcnE4ww==",
"dependencies": {
"rrweb": "2.0.0-alpha.18"
}
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
@ -8130,6 +8153,32 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -8446,64 +8495,6 @@
"points-on-path": "^0.2.1"
}
},
"node_modules/rrdom": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.18.tgz",
"integrity": "sha512-fSFzFFxbqAViITyYVA4Z0o5G6p1nEqEr/N8vdgSKie9Rn0FJxDSNJgjV0yiCIzcDs0QR+hpvgFhpbdZ6JIr5Nw==",
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.18"
}
},
"node_modules/rrweb": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.18.tgz",
"integrity": "sha512-1mjZcB+LVoGSx1+i9E2ZdAP90fS3MghYVix2wvGlZvrgRuLCbTCCOZMztFCkKpgp7/EeCdYM4nIHJkKX5J1Nmg==",
"dependencies": {
"@rrweb/types": "^2.0.0-alpha.18",
"@rrweb/utils": "^2.0.0-alpha.18",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.0.0-alpha.18",
"rrweb-snapshot": "^2.0.0-alpha.18"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz",
"integrity": "sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA==",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rrweb-snapshot/node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -9612,6 +9603,15 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",