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
- Admin navigates to Settings → AI Settings
- Admin configures AI provider, model, tone, difficulty levels, etc.
- Admin clicks “Save”
- Success message appears (misleading)
- Admin refreshes page
- 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 identifierdefault_tone- Content tone (friendly, professional, academic, casual, inspirational)default_difficulty- Difficulty level (beginner, intermediate, advanced)max_concurrent_generations- Concurrency limitvideo_generation_enabled- Video feature togglevideo_avatar_id- HeyGen avatar identifiervideo_voice_id- HeyGen voice identifiercontent_review_required- Review workflow toggle
Backend only accepted 5 legacy fields:
default_modelmax_tokenstemperatureenable_video_generationvideo_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_idandvideo_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 Bedrockai_model: "anthropic.claude-3-5-sonnet..."- Current production modeldefault_tone: "professional"- Safe default for LMS contentdefault_difficulty: "intermediate"- Balanced starting pointmax_concurrent_generations: 3- Reasonable concurrency limitvideo_generation_enabled: false- Opt-in featurecontent_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
- Run Database Migration
./scripts/database/db-migrate.sh - Deploy Backend
./scripts/deployment/deploy-backend.sh - Verify Deployment
- Check Lambda function updated
- Check CloudWatch logs for errors
Post-Deployment Verification
- Navigate to Admin Panel → Settings → AI Settings
- Modify all AI settings fields
- Click Save
- Refresh the page
- Verify all settings persisted
Rollback Plan
If issues occur:
- Revert Lambda: Deploy previous version from S3
- Database: Migration uses
ON CONFLICT DO NOTHING, no rollback needed - 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:
- Better type inference with Zod schemas
- Smaller bundle size (no runtime enum object)
- JSON serialization works without transformation
- Consistency with existing codebase patterns
Why 100 Character Limit on String Fields?
The max(100) constraint was added to:
- Prevent abuse - Unreasonably long values
- Database alignment - VARCHAR(100) is sufficient
- 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
Related Documentation
Last Updated: 2026-01-22 Status: Ready for Review