PR #428: AI Settings Persistence Fix

Quick Reference

Field Value
PR Number #428
Branch feature/tried-setting-up-ai-models-but-GQdZbA
Status Open
Author Alper Tovi
Created 2026-01-22
Root Cause Backend schema mismatch - 9 fields silently dropped during validation
Files Changed 6 files (+437/-2 lines)
Priority High

Problem Description

User-Reported Issue

AI Models settings configured through the Admin Panel did not persist after saving. Users would configure AI settings (provider, model, tone, difficulty, etc.), click save, and upon page refresh, all settings would revert to defaults.

Symptoms

  1. Admin navigates to Settings → AI Settings
  2. Admin configures AI provider, model, tone, difficulty levels, etc.
  3. Admin clicks “Save”
  4. Success message appears (misleading)
  5. Admin refreshes page
  6. All AI settings revert to previous/default values

Root Cause Analysis

Investigation Findings

The root cause was a schema mismatch between the frontend and backend:

Frontend sends 9 new fields:

  • ai_provider - AI provider selection (bedrock, openai)
  • ai_model - Specific model identifier
  • default_tone - Content tone (friendly, professional, academic, casual, inspirational)
  • default_difficulty - Difficulty level (beginner, intermediate, advanced)
  • max_concurrent_generations - Concurrency limit
  • video_generation_enabled - Video feature toggle
  • video_avatar_id - HeyGen avatar identifier
  • video_voice_id - HeyGen voice identifier
  • content_review_required - Review workflow toggle

Backend only accepted 5 legacy fields:

  • default_model
  • max_tokens
  • temperature
  • enable_video_generation
  • video_provider

Data Loss Mechanism

Frontend Request                Backend Validation
┌─────────────────┐            ┌─────────────────┐
│ ai_provider     │───────────>│     DROPPED     │
│ ai_model        │───────────>│     DROPPED     │
│ default_tone    │───────────>│     DROPPED     │
│ default_difficulty│─────────>│     DROPPED     │
│ max_concurrent..│───────────>│     DROPPED     │
│ video_generation│───────────>│     DROPPED     │
│ video_avatar_id │───────────>│     DROPPED     │
│ video_voice_id  │───────────>│     DROPPED     │
│ content_review..│───────────>│     DROPPED     │
│                 │            │                 │
│ default_model   │───────────>│    ACCEPTED     │
│ max_tokens      │───────────>│    ACCEPTED     │
│ temperature     │───────────>│    ACCEPTED     │
│ enable_video..  │───────────>│    ACCEPTED     │
│ video_provider  │───────────>│    ACCEPTED     │
└─────────────────┘            └─────────────────┘

The Zod schema used .partial() which made unknown fields silently pass through validation but not get saved to the database.


Solution Implementation

1. Type Definitions Update

File: backend/functions/settings/src/types/settings.types.ts

Added new type aliases for type safety:

export type AIProvider = 'bedrock' | 'openai';
export type ContentTone = 'friendly' | 'professional' | 'academic' | 'casual' | 'inspirational';
export type DifficultyLevel = 'beginner' | 'intermediate' | 'advanced';
export type VideoProvider = 'heygen' | 'synthesia' | 'd-id';

Updated AISettings interface with clear separation:

export interface AISettings {
  /**
   * Primary fields (frontend schema) - PREFERRED
   */
  ai_provider?: AIProvider;
  ai_model?: string;
  default_tone?: ContentTone;
  default_difficulty?: DifficultyLevel;
  max_concurrent_generations?: number;
  video_generation_enabled?: boolean;
  video_avatar_id?: string;
  video_voice_id?: string;
  content_review_required?: boolean;

  /**
   * Legacy fields (backward compatibility) - DEPRECATED
   * @deprecated Use ai_model instead of default_model
   * @deprecated Use video_generation_enabled instead of enable_video_generation
   */
  default_model?: string;
  max_tokens?: number;
  temperature?: number;
  enable_video_generation?: boolean;
  video_provider?: VideoProvider;
}

2. Zod Schema Validation Update

File: backend/functions/settings/src/utils/validation.ts

Added comprehensive validation for all 14 fields (9 new + 5 legacy):

export const AISettingsSchema = z.object({
  // Primary fields (frontend schema) - PREFERRED
  ai_provider: z.enum(['bedrock', 'openai']).optional(),
  ai_model: z.string().max(100).optional(),
  default_tone: z.enum(['friendly', 'professional', 'academic', 'casual', 'inspirational']).optional(),
  default_difficulty: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
  max_concurrent_generations: z.number().int().min(1).max(10).optional(),
  video_generation_enabled: z.boolean().optional(),
  video_avatar_id: z.string().max(100).optional().or(z.literal('')),
  video_voice_id: z.string().max(100).optional().or(z.literal('')),
  content_review_required: z.boolean().optional(),

  // Legacy fields (backward compatibility) - DEPRECATED
  default_model: z.string().max(100).optional(),
  max_tokens: z.number().int().min(1).max(100000).optional(),
  temperature: z.number().min(0).max(1).optional(),
  enable_video_generation: z.boolean().optional(),
  video_provider: z.enum(['heygen', 'synthesia', 'd-id']).optional()
});

Key Validation Rules:

  • Enum validation for ai_provider, default_tone, default_difficulty, video_provider
  • String length constraints (max 100 chars) to prevent abuse
  • Empty string support for optional video_avatar_id and video_voice_id
  • Integer constraints for max_concurrent_generations (1-10)
  • Float constraints for temperature (0-1)

3. Database Migration

File: backend/migrations/028_add_ai_settings_fields.sql

INSERT INTO platform_settings (id, section, key, value, data_type, description, created_at, updated_at)
VALUES
  (gen_random_uuid(), 'ai', 'ai_provider', '"bedrock"', 'string', 'AI provider (bedrock, openai)', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'ai_model', '"anthropic.claude-3-5-sonnet-20241022-v2:0"', 'string', 'AI model identifier', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'default_tone', '"professional"', 'string', 'Default content tone', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'default_difficulty', '"intermediate"', 'string', 'Default content difficulty level', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'max_concurrent_generations', '3', 'number', 'Maximum concurrent AI generation jobs', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'video_generation_enabled', 'false', 'boolean', 'Enable video generation', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'video_avatar_id', '""', 'string', 'Default video avatar ID', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'video_voice_id', '""', 'string', 'Default video voice ID', NOW(), NOW()),
  (gen_random_uuid(), 'ai', 'content_review_required', 'true', 'boolean', 'Require content review before publishing', NOW(), NOW())
ON CONFLICT (section, key) DO NOTHING;

Default Values Rationale:

  • ai_provider: "bedrock" - Platform uses Amazon Bedrock
  • ai_model: "anthropic.claude-3-5-sonnet..." - Current production model
  • default_tone: "professional" - Safe default for LMS content
  • default_difficulty: "intermediate" - Balanced starting point
  • max_concurrent_generations: 3 - Reasonable concurrency limit
  • video_generation_enabled: false - Opt-in feature
  • content_review_required: true - Safe default requiring review

4. Unit Tests

File: backend/functions/settings/src/__tests__/utils/validation.test.ts

Added 10 new comprehensive tests covering:

Test Description
Frontend AI settings validation All 9 new fields with valid values
ai_provider enum validation Accept bedrock/openai, reject invalid
default_tone enum validation Accept 5 valid tones, reject invalid
default_difficulty enum validation Accept 3 levels, reject invalid
max_concurrent_generations bounds Reject 0, reject >10
Combined frontend + legacy settings Both field sets work together
Empty string for video_avatar_id Accepts empty string
Empty string for video_voice_id Accepts empty string
ai_model length constraint Reject >100 characters
video_avatar_id length constraint Reject >100 characters
video_voice_id length constraint Reject >100 characters
default_model length constraint Legacy field also constrained

Files Changed

File Changes Purpose
backend/functions/settings/src/types/settings.types.ts +43/-1 Add type aliases and interface fields
backend/functions/settings/src/utils/validation.ts +18/-1 Extend Zod schema with new fields
backend/functions/settings/src/__tests__/utils/validation.test.ts +201/-0 Comprehensive test coverage
backend/migrations/028_add_ai_settings_fields.sql +15/-0 Database default values
.gitignore +2/-0 Ignore refinement prompts
prompts/build/84-tried-setting-up-ai-models-but.md +158/-0 Refinement prompt for debugging

Deployment Checklist

Pre-Deployment

  • TypeScript compilation passes (npm run build)
  • All 70 validation tests pass (npm test -- --testPathPattern=validation.test.ts)

Deployment Steps

  1. Run Database Migration
    ./scripts/database/db-migrate.sh
    
  2. Deploy Backend
    ./scripts/deployment/deploy-backend.sh
    
  3. Verify Deployment
    • Check Lambda function updated
    • Check CloudWatch logs for errors

Post-Deployment Verification

  1. Navigate to Admin Panel → Settings → AI Settings
  2. Modify all AI settings fields
  3. Click Save
  4. Refresh the page
  5. Verify all settings persisted

Rollback Plan

If issues occur:

  1. Revert Lambda: Deploy previous version from S3
  2. Database: Migration uses ON CONFLICT DO NOTHING, no rollback needed
  3. Frontend: No changes required

Technical Decisions

Why String Literal Unions Instead of Enums?

TypeScript string literal unions (type AIProvider = 'bedrock' | 'openai') were chosen over TypeScript enums because:

  1. Better type inference with Zod schemas
  2. Smaller bundle size (no runtime enum object)
  3. JSON serialization works without transformation
  4. Consistency with existing codebase patterns

Why 100 Character Limit on String Fields?

The max(100) constraint was added to:

  1. Prevent abuse - Unreasonably long values
  2. Database alignment - VARCHAR(100) is sufficient
  3. UI consistency - Display without truncation issues

Why Support Empty Strings for Video IDs?

video_avatar_id: z.string().max(100).optional().or(z.literal(''))

The frontend may send empty strings when video generation is disabled but the fields exist in the form. This prevents validation errors while maintaining data integrity.


Lessons Learned

1. Schema Evolution Communication

When frontend adds new fields, backend must be updated in sync. Consider:

  • Shared type definitions
  • API contract documentation
  • Integration tests that catch mismatches

2. Silent Validation Failures

Zod’s .partial() on objects with extra fields doesn’t error - it silently drops unknown fields. Consider:

  • Using .strict() for APIs where extra fields indicate bugs
  • Logging when unknown fields are received
  • End-to-end tests that verify round-trip data integrity

3. Default Value Strategy

Database migrations should include sensible defaults for new fields to ensure:

  • Existing functionality continues working
  • New installations have reasonable starting values
  • No NULL handling edge cases in code


Last Updated: 2026-01-22 Status: Ready for Review


Back to top

Momentum LMS © 2025. Distributed under the MIT license.