From 8b737812722969a675dd0e62e64830412205043f Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Thu, 1 Jan 2026 09:06:24 +0000 Subject: [PATCH] Update --- .../src/components/ai/AiGeneratorDialog.tsx | 44 +- apps/admin/src/hooks/useAiConfig.ts | 7 +- apps/admin/src/lib/openai.ts | 433 ++++++++++++++++-- apps/admin/src/pages/AiSettingsPage.tsx | 137 ++++-- ...101000001_add_council_generators_array.sql | 21 + 5 files changed, 567 insertions(+), 75 deletions(-) create mode 100644 apps/supabase/migrations/20260101000001_add_council_generators_array.sql diff --git a/apps/admin/src/components/ai/AiGeneratorDialog.tsx b/apps/admin/src/components/ai/AiGeneratorDialog.tsx index 361dbd4..4885325 100644 --- a/apps/admin/src/components/ai/AiGeneratorDialog.tsx +++ b/apps/admin/src/components/ai/AiGeneratorDialog.tsx @@ -28,6 +28,7 @@ import { type ToneLevel, type QuestionReview, type CouncilGenerationResult, + type GenerationCandidate, } from '@/lib/openai'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; @@ -81,6 +82,8 @@ export function AiGeneratorDialog({ const [reviews, setReviews] = useState([]); const [reviewSummary, setReviewSummary] = useState(null); const [reviewMetadata, setReviewMetadata] = useState(null); + const [selectedGeneratorIndex, setSelectedGeneratorIndex] = useState(null); + const [allCandidates, setAllCandidates] = useState(null); const [filter, setFilter] = useState<'all' | 'pass' | 'flag' | 'reject'>('all'); // Selection state for questions @@ -115,6 +118,8 @@ export function AiGeneratorDialog({ setReviews([]); setReviewSummary(null); setReviewMetadata(null); + setSelectedGeneratorIndex(null); + setAllCandidates(null); setFilter('all'); try { @@ -147,6 +152,8 @@ export function AiGeneratorDialog({ setReviews(reviewList); setReviewSummary(result.summary); setReviewMetadata(result.metadata); + setSelectedGeneratorIndex(result.selectedGeneratorIndex); + setAllCandidates(result.allCandidates); // Smart selection based on review results if (reviewList.length > 0) { @@ -162,10 +169,13 @@ export function AiGeneratorDialog({ // Show toast with summary if (result.summary) { const { passed, flagged, rejected } = result.summary; + const generatorInfo = allCandidates && allCandidates.length > 1 && result.selectedGeneratorIndex !== null + ? ` (from generator ${result.selectedGeneratorIndex + 1})` + : ''; if (rejected > 0) { - toast.warning(`Review complete: ${passed} passed, ${flagged} flagged, ${rejected} rejected`); + toast.warning(`Review complete: ${passed} passed, ${flagged} flagged, ${rejected} rejected${generatorInfo}`); } else { - toast.success(`Review complete: ${passed} passed, ${flagged} flagged`); + toast.success(`Review complete: ${passed} passed, ${flagged} flagged${generatorInfo}`); } } } else { @@ -242,6 +252,8 @@ export function AiGeneratorDialog({ setReviews([]); setReviewSummary(null); setReviewMetadata(null); + setSelectedGeneratorIndex(null); + setAllCandidates(null); setFilter('all'); onOpenChange(false); }; @@ -369,9 +381,9 @@ export function AiGeneratorDialog({ <> {loadingStep === 'generating' && councilConfig.enabled - ? 'Step 1/2: Generating...' + ? `Step 1/2: Generating${councilConfig.generators.length > 1 ? ` with ${councilConfig.generators.length} models` : ''}...` : loadingStep === 'reviewing' - ? 'Step 2/2: Reviewing...' + ? 'Step 2/2: Reviewing & selecting best...' : 'Generating...'} ) : ( @@ -383,7 +395,8 @@ export function AiGeneratorDialog({ {councilConfig.enabled && type === 'questions' && !loading && (

- Council mode enabled - questions will be reviewed by a second model + Council mode: {councilConfig.generators.length} generator{councilConfig.generators.length > 1 ? 's' : ''} + reviewer + {councilConfig.generators.length > 1 && ' (best output will be selected)'}

)} @@ -426,10 +439,23 @@ export function AiGeneratorDialog({ {reviewMetadata && ( -

- Generated in {(reviewMetadata.generationTime / 1000).toFixed(1)}s - {reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`} -

+
+

+ Generated in {(reviewMetadata.totalGenerationTime / 1000).toFixed(1)}s + {reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`} +

+ {allCandidates && allCandidates.length > 1 && selectedGeneratorIndex !== null && ( +

+ Selected: Generator {selectedGeneratorIndex + 1} of {allCandidates.length} + {' '}({allCandidates[selectedGeneratorIndex]?.generatorModel.split('/').pop()}) +

+ )} + {reviewMetadata.generatorModels.length > 1 && ( +

+ Compared {reviewMetadata.generatorModels.length} generators: {reviewMetadata.generatorModels.map(m => m.split('/').pop()).join(', ')} +

+ )} +
)} )} diff --git a/apps/admin/src/hooks/useAiConfig.ts b/apps/admin/src/hooks/useAiConfig.ts index 2b6ef5e..d7fe294 100644 --- a/apps/admin/src/hooks/useAiConfig.ts +++ b/apps/admin/src/hooks/useAiConfig.ts @@ -1,6 +1,10 @@ import { useState, useEffect, useCallback } from 'react'; import { supabase } from '@/config'; +export interface CouncilGenerator { + model: string; +} + export interface AiConfig { id: string; openrouter_api_key: string | null; @@ -9,7 +13,8 @@ export interface AiConfig { model_fix: string | null; model_polish: string | null; council_enabled: boolean; - council_generator_model: string | null; + council_generator_model: string | null; // Legacy - kept for backwards compatibility + council_generators: CouncilGenerator[] | null; // New array of generators council_reviewer_model: string | null; updated_at: string | null; updated_by: string | null; diff --git a/apps/admin/src/lib/openai.ts b/apps/admin/src/lib/openai.ts index 5cca677..b980120 100644 --- a/apps/admin/src/lib/openai.ts +++ b/apps/admin/src/lib/openai.ts @@ -75,20 +75,34 @@ const getModel = (purpose?: ModelPurpose) => { // COUNCIL CONFIGURATION - Multi-model review system // ============================================================================= +export interface CouncilGenerator { + model: string; +} + export interface CouncilConfig { enabled: boolean; - generatorModel: string; + generators: CouncilGenerator[]; reviewerModel: string; } +const DEFAULT_GENERATORS: CouncilGenerator[] = [{ model: 'anthropic/claude-3.5-sonnet' }]; + export function getCouncilConfig(): CouncilConfig { // First, try to get config from remote const remoteConfig = getCachedAiConfig(); if (remoteConfig) { + // Use council_generators array if available, otherwise fall back to legacy single model + let generators: CouncilGenerator[] = DEFAULT_GENERATORS; + if (remoteConfig.council_generators && Array.isArray(remoteConfig.council_generators) && remoteConfig.council_generators.length > 0) { + generators = remoteConfig.council_generators; + } else if (remoteConfig.council_generator_model) { + generators = [{ model: remoteConfig.council_generator_model }]; + } + return { enabled: remoteConfig.council_enabled || false, - generatorModel: remoteConfig.council_generator_model || 'anthropic/claude-3.5-sonnet', + generators, reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5', }; } @@ -96,7 +110,7 @@ export function getCouncilConfig(): CouncilConfig { // Fall back to env vars return { enabled: import.meta.env.VITE_COUNCIL_ENABLED === 'true', - generatorModel: import.meta.env.VITE_COUNCIL_GENERATOR_MODEL || 'anthropic/claude-3.5-sonnet', + generators: [{ model: import.meta.env.VITE_COUNCIL_GENERATOR_MODEL || 'anthropic/claude-3.5-sonnet' }], reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5', }; } @@ -132,14 +146,23 @@ export interface ReviewResult { }; } +export interface GenerationCandidate { + generatorIndex: number; + generatorModel: string; + questions: GeneratedQuestion[]; + generationTime: number; +} + export interface CouncilGenerationResult { questions: GeneratedQuestion[]; reviews: QuestionReview[]; summary: ReviewResult['summary'] | null; + selectedGeneratorIndex: number | null; // Which generator was chosen by reviewer + allCandidates: GenerationCandidate[] | null; // All generations for transparency metadata: { - generatorModel: string; + generatorModels: string[]; reviewerModel: string | null; - generationTime: number; + totalGenerationTime: number; reviewTime: number; }; } @@ -1360,6 +1383,301 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues }; } +/** + * Generate questions using a specific model + */ +async function generateQuestionsWithModel( + model: string, + packName: string, + count: number, + intensity: number | undefined, + tone: ToneLevel, + packDescription: string | undefined, + existingQuestions: string[] | undefined, + crudeLang: boolean, + inspiration: string | undefined +): Promise { + const openai = getOpenAI(); + const isClean = tone === 0; + const isExplicit = tone >= 4; + + const crudeLangInstruction = crudeLang + ? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.' + : ''; + + const inspirationInstruction = inspiration + ? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide the types of questions you generate.` + : ''; + + const intensityInstruction = isClean + ? 'All questions should be intensity level 1 (non-physical activities).' + : intensity + ? `All questions should be at intensity level ${intensity}.` + : 'Vary the intensity levels from 1 to 5 across the questions for good variety.'; + + const toneInstructions: Record = { + 0: 'WILDNESS LEVEL: EVERYDAY. Generate non-romantic activities only. Focus on communication, teamwork, fun, personal growth, and bonding WITHOUT any romantic or sexual undertones. Examples: "Cook a new recipe together", "Play a board game", "Send your partner a compliment while they\'re at work", "Plan a weekend adventure". NO flirting, NO romance, NO intimacy.', + 1: 'WILDNESS LEVEL: SWEET. Generate romantic but innocent activities. Focus on emotional connection, love, and tenderness. Examples: "Write your partner a love letter", "Plan a surprise date night", "Cuddle and watch the sunset", "Give your partner a back massage". NO sexual content.', + 2: 'WILDNESS LEVEL: FLIRTY. Generate playful, teasing activities with light innuendo. Examples: "Send a flirty text during the day", "Whisper something suggestive in your partner\'s ear", "Give your partner a lingering kiss goodbye". Suggestive but NOT explicitly sexual.', + 3: 'WILDNESS LEVEL: INTIMATE. Generate sensual activities that imply intimacy without being graphic. Examples: "Give your partner a full-body massage", "Shower together", "Make out somewhere unexpected", "Sleep naked together". Passionate but soft - no explicit sex acts.', + 4: 'WILDNESS LEVEL: SEXUAL. Generate standard sexual activities. Examples: "Go down on your partner", "Have sex in a new position", "Use a vibrator together", "Wake your partner up with oral". Direct language, avoid clinical terms. This is vanilla sex - adventurous but not extreme.', + 5: 'WILDNESS LEVEL: WILD. Generate extreme, kinky, or taboo activities. Think: public sex, exhibitionism, BDSM, risky situations, intense kinks. Examples: "Get creampied by your partner and go to dinner", "Have sex somewhere you might get caught", "Let your partner tie you up and use you", "Edge each other for an hour before allowing release". Push boundaries. Use crude terms when they ARE the activity (cum, cock ring) but tasteful phrasing for general acts (have sex, not fuck). NEVER sanitize "cum" to "come".', + }; + + const toneInstruction = toneInstructions[tone]; + + const symmetricExamples = isClean + ? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".' + : isExplicit + ? 'GOOD: "Fuck in a risky place", "Try a new position", "Sixty-nine together", "Watch porn and recreate a scene".' + : 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".'; + + const asymmetricExamples = isClean + ? `Examples: + - text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal" + - text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner" + - text: "Give your partner a compliment" → partner_text: "Receive a compliment from your partner"` + : isExplicit + ? `Examples: + - text: "Go down on your partner" → partner_text: "Have your partner go down on you" + - text: "Send your partner a nude" → partner_text: "Receive a nude from your partner" + - text: "Tie your partner up" → partner_text: "Get tied up by your partner" + - text: "Spank your partner" → partner_text: "Get spanked by your partner"` + : `Examples: + - text: "Give your partner a massage" → partner_text: "Receive a massage from your partner" + - text: "Write your partner a love letter" → partner_text: "Receive a love letter from your partner" + - text: "Plan a surprise date for your partner" → partner_text: "Be surprised with a date by your partner"`; + + const explicitWarning = isClean + ? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.' + : isExplicit + ? '' + : '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.'; + + const descriptionContext = packDescription + ? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.` + : ''; + + const existingQuestionsContext = existingQuestions && existingQuestions.length > 0 + ? `\n\nEXISTING QUESTIONS IN THIS PACK (DO NOT DUPLICATE OR CREATE SIMILAR VARIATIONS): +${existingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')} + +CRITICAL: Generate completely NEW and DIFFERENT questions. Do not repeat any of the above questions or create minor variations of them. Each new question must offer a genuinely distinct activity or experience.` + : ''; + + const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}${existingQuestionsContext} + +${INTENSITY_GUIDE_SHORT} + +${intensityInstruction} +${toneInstruction}${explicitWarning}${crudeLangInstruction}${inspirationInstruction} + +IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe). +Cards should be "Proposals" for specific actions, NOT interview questions. + +CRITICAL LANGUAGE RULES: +- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her" +- BAD: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..." +- GOOD: "Send your partner a photo", "Spank your partner", "Give your partner..." +- The card reader is the DOER. Their partner is "your partner". + +For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other): + +1. SYMMETRIC Activities (partner_text = null): + - Both partners do the same thing together. + - ${symmetricExamples} + +2. ASYMMETRIC Actions (requires partner_text): + - One partner does something TO/FOR the other, or roles differ. + - text = what the INITIATOR does + - partner_text = what the RECEIVER does/experiences + - ${asymmetricExamples} + +Required JSON structure: +- text: The proposal (DOER's perspective, uses "your partner") +- partner_text: For asymmetric only - RECEIVER's perspective +- intensity: 1-5 based on INTENSITY LEVELS above. +- requires_props: (optional) Array of items needed +- location_type: (optional) "home" | "public" | "outdoors" | "travel" | "anywhere" +- effort_level: (optional) "spontaneous" | "low" | "medium" | "planned" + +Return a JSON object with a "questions" array.`; + + const systemMessages: Record = { + 0: 'You are a content writer for a couples app. Generate everyday activities focused on bonding, communication, and fun. No romance or intimacy. Always respond with valid JSON only.', + 1: 'You are a content writer for a couples app. Generate sweet, romantic activities that help couples connect emotionally. Keep it innocent and wholesome. Always respond with valid JSON only.', + 2: 'You are a content writer for a couples app. Generate flirty, playful activities with light teasing and innuendo. Suggestive but not sexual. Always respond with valid JSON only.', + 3: 'You are a content writer for a couples app. Generate intimate, sensual activities that imply passion without being explicit. Always respond with valid JSON only.', + 4: 'You are a content writer for an adult couples app. Generate sexual activities with direct, natural language. Write like a sex-positive friend - not clinical. Always respond with valid JSON only.', + 5: 'You are a content writer for an adult couples app. Generate wild, extreme, kinky activities that push boundaries. Use crude terms when they ARE the act but tasteful phrasing for general sex/oral. Never use clinical terms. Always respond with valid JSON only.', + }; + + const response = await openai.chat.completions.create({ + model: model, + messages: [ + { + role: 'system', + content: systemMessages[tone], + }, + { role: 'user', content: prompt }, + ], + response_format: { type: 'json_object' }, + temperature: 0.9, + }); + + const content = response.choices[0].message.content; + if (!content) throw new Error('No content generated'); + + const parsed = JSON.parse(content); + return parsed.questions as GeneratedQuestion[]; +} + +/** + * Ask the reviewer to select the best generation from multiple candidates + */ +export interface SelectionResult { + selectedIndex: number; + reviews: QuestionReview[]; + summary: ReviewResult['summary']; + reasoning: string; +} + +async function selectBestGeneration( + candidates: GenerationCandidate[], + packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel } +): Promise { + const openai = getOpenAI(); + const config = getCouncilConfig(); + + // If only one candidate, just review it normally + if (candidates.length === 1) { + const reviewResult = await reviewGeneratedQuestions(candidates[0].questions, packContext); + return { + selectedIndex: 0, + reviews: reviewResult.reviews, + summary: reviewResult.summary, + reasoning: 'Single generator - no selection needed', + }; + } + + const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown'; + + // Build prompt for selection + const candidatesForReview = candidates.map((c, i) => ({ + generatorIndex: i, + generatorModel: c.generatorModel, + questions: c.questions.map((q, qi) => ({ + index: qi, + text: q.text, + partner_text: q.partner_text || null, + intensity: q.intensity, + })), + })); + + const prompt = `You are a quality reviewer for a couples intimacy app. You have received ${candidates.length} different sets of generated questions from different AI models. Your task is to: + +1. COMPARE all sets and SELECT the BEST one overall +2. REVIEW the selected set in detail + +PACK CONTEXT: +- Pack Name: "${packContext.name}" +${packContext.description ? `- Pack Description: "${packContext.description}"` : ''} +- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'} +- Tone Level: ${packContext.tone} (${toneDescription}) + +${REVIEW_GUIDELINES} + +CANDIDATE SETS: +${JSON.stringify(candidatesForReview, null, 2)} + +SELECTION CRITERIA (in order of importance): +1. Overall quality and creativity of questions +2. Variety and uniqueness within the set +3. Adherence to pack theme and tone +4. Proper intensity grading +5. Correct use of "your partner" language +6. Quality of partner_text for asymmetric questions + +Return a JSON object with: +{ + "selectedIndex": <0-based index of the best candidate set>, + "reasoning": "", + "reviews": [ + { + "index": , + "verdict": "pass" | "flag" | "reject", + "issues": ["Issue 1", "Issue 2"], + "suggestions": "Optional improvement suggestion", + "scores": { + "guidelineCompliance": 1-10, + "creativity": 1-10, + "clarity": 1-10, + "intensityAccuracy": 1-10 + } + } + ] +} + +Select the best set and review ALL questions in that set.`; + + const response = await openai.chat.completions.create({ + model: config.reviewerModel, + messages: [ + { + role: 'system', + content: 'You are a quality assurance reviewer for a couples intimacy app. You compare multiple AI-generated question sets and select the best one, then provide detailed reviews. Be thorough but fair. Always respond with valid JSON only.', + }, + { role: 'user', content: prompt }, + ], + response_format: { type: 'json_object' }, + temperature: 0.3, + }); + + const content = response.choices[0].message.content; + if (!content) throw new Error('No selection content generated'); + + // Strip markdown code blocks if present + let jsonContent = content.trim(); + if (jsonContent.startsWith('```')) { + jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); + } + + const parsed = JSON.parse(jsonContent); + const selectedIndex = parsed.selectedIndex ?? 0; + const reviews: QuestionReview[] = parsed.reviews || []; + const reasoning = parsed.reasoning || 'No reasoning provided'; + + // Calculate summary + const passed = reviews.filter(r => r.verdict === 'pass').length; + const flagged = reviews.filter(r => r.verdict === 'flag').length; + const rejected = reviews.filter(r => r.verdict === 'reject').length; + + let totalScore = 0; + let scoreCount = 0; + for (const review of reviews) { + if (review.scores) { + totalScore += review.scores.guidelineCompliance || 0; + totalScore += review.scores.creativity || 0; + totalScore += review.scores.clarity || 0; + totalScore += review.scores.intensityAccuracy || 0; + scoreCount += 4; + } + } + const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0; + + return { + selectedIndex, + reviews, + summary: { + passed, + flagged, + rejected, + overallQuality, + }, + reasoning, + }; +} + export async function generateQuestionsWithCouncil( packName: string, count: number = 10, @@ -1372,40 +1690,79 @@ export async function generateQuestionsWithCouncil( ): Promise { const config = getCouncilConfig(); - // Step 1: Generate questions - const startGen = Date.now(); - const questions = await generateQuestions( - packName, - count, - intensity, - tone, - packDescription, - existingQuestions, - crudeLang, - inspiration - ); - const genTime = Date.now() - startGen; - - // Step 2: Review if council is enabled + // If council is disabled, use the default generation model if (!config.enabled) { + const startGen = Date.now(); + const questions = await generateQuestions( + packName, + count, + intensity, + tone, + packDescription, + existingQuestions, + crudeLang, + inspiration + ); + const genTime = Date.now() - startGen; + return { questions, reviews: [], summary: null, + selectedGeneratorIndex: null, + allCandidates: null, metadata: { - generatorModel: getModel('generate'), + generatorModels: [getModel('generate')], reviewerModel: null, - generationTime: genTime, + totalGenerationTime: genTime, reviewTime: 0, }, }; } + // Step 1: Generate questions with all configured generators in parallel + const startGen = Date.now(); + const generatorPromises = config.generators.map(async (gen, index) => { + const genStart = Date.now(); + try { + const questions = await generateQuestionsWithModel( + gen.model, + packName, + count, + intensity, + tone, + packDescription, + existingQuestions, + crudeLang, + inspiration + ); + return { + generatorIndex: index, + generatorModel: gen.model, + questions, + generationTime: Date.now() - genStart, + } as GenerationCandidate; + } catch (error) { + console.error(`Generator ${index} (${gen.model}) failed:`, error); + return null; + } + }); + + const results = await Promise.all(generatorPromises); + const successfulCandidates = results.filter((r): r is GenerationCandidate => r !== null); + const totalGenerationTime = Date.now() - startGen; + + // If no generators succeeded, throw error + if (successfulCandidates.length === 0) { + throw new Error('All generators failed to produce results'); + } + + // Step 2: Have the reviewer select the best generation and review it const startReview = Date.now(); const isExplicit = tone >= 4; try { - const reviewResult = await reviewGeneratedQuestions(questions, { + const selectionResult = await selectBestGeneration(successfulCandidates, { name: packName, description: packDescription, isExplicit, @@ -1413,28 +1770,36 @@ export async function generateQuestionsWithCouncil( }); const reviewTime = Date.now() - startReview; + const selectedCandidate = successfulCandidates[selectionResult.selectedIndex]; + return { - questions, - reviews: reviewResult.reviews, - summary: reviewResult.summary, + questions: selectedCandidate.questions, + reviews: selectionResult.reviews, + summary: selectionResult.summary, + selectedGeneratorIndex: selectionResult.selectedIndex, + allCandidates: successfulCandidates, metadata: { - generatorModel: config.generatorModel, + generatorModels: config.generators.map(g => g.model), reviewerModel: config.reviewerModel, - generationTime: genTime, + totalGenerationTime, reviewTime, }, }; } catch (error) { - // If review fails, return questions without review (with warning in metadata) - console.error('Council review failed:', error); + // If selection/review fails, return the first successful generation without review + console.error('Council selection/review failed:', error); + const firstCandidate = successfulCandidates[0]; + return { - questions, + questions: firstCandidate.questions, reviews: [], summary: null, + selectedGeneratorIndex: 0, + allCandidates: successfulCandidates, metadata: { - generatorModel: config.generatorModel, - reviewerModel: null, // null indicates review failed - generationTime: genTime, + generatorModels: config.generators.map(g => g.model), + reviewerModel: null, + totalGenerationTime, reviewTime: 0, }, }; diff --git a/apps/admin/src/pages/AiSettingsPage.tsx b/apps/admin/src/pages/AiSettingsPage.tsx index 443f1e0..f574fbe 100644 --- a/apps/admin/src/pages/AiSettingsPage.tsx +++ b/apps/admin/src/pages/AiSettingsPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useAiConfig, AiConfig } from '@/hooks/useAiConfig'; +import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Save, Eye, EyeOff, AlertTriangle, Bot, Users, Sparkles, RefreshCw } from 'lucide-react'; +import { Save, Eye, EyeOff, AlertTriangle, Bot, Users, Sparkles, RefreshCw, Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { format } from 'date-fns'; @@ -46,6 +46,11 @@ export function AiSettingsPage() { // Initialize form data when config loads useEffect(() => { if (config) { + // Handle generators array - default to one generator with the legacy model or default + const generators: CouncilGenerator[] = config.council_generators && config.council_generators.length > 0 + ? config.council_generators + : [{ model: config.council_generator_model || 'anthropic/claude-3.5-sonnet' }]; + setFormData({ openrouter_api_key: config.openrouter_api_key || '', default_model: config.default_model || '', @@ -54,6 +59,7 @@ export function AiSettingsPage() { model_polish: config.model_polish || '', council_enabled: config.council_enabled || false, council_generator_model: config.council_generator_model || '', + council_generators: generators, council_reviewer_model: config.council_reviewer_model || '', }); setHasChanges(false); @@ -84,6 +90,10 @@ export function AiSettingsPage() { const handleReset = () => { if (config) { + const generators: CouncilGenerator[] = config.council_generators && config.council_generators.length > 0 + ? config.council_generators + : [{ model: config.council_generator_model || 'anthropic/claude-3.5-sonnet' }]; + setFormData({ openrouter_api_key: config.openrouter_api_key || '', default_model: config.default_model || '', @@ -92,12 +102,35 @@ export function AiSettingsPage() { model_polish: config.model_polish || '', council_enabled: config.council_enabled || false, council_generator_model: config.council_generator_model || '', + council_generators: generators, council_reviewer_model: config.council_reviewer_model || '', }); setHasChanges(false); } }; + // Helper functions for managing generators array + const generators = (formData.council_generators as CouncilGenerator[]) || [{ model: 'anthropic/claude-3.5-sonnet' }]; + + const addGenerator = () => { + const newGenerators = [...generators, { model: '' }]; + handleChange('council_generators', newGenerators); + }; + + const removeGenerator = (index: number) => { + if (generators.length <= 1) { + toast.error('You must have at least one generator'); + return; + } + const newGenerators = generators.filter((_, i) => i !== index); + handleChange('council_generators', newGenerators); + }; + + const updateGeneratorModel = (index: number, model: string) => { + const newGenerators = generators.map((g, i) => i === index ? { ...g, model } : g); + handleChange('council_generators', newGenerators); + }; + if (!canManage) { return (
@@ -318,7 +351,7 @@ export function AiSettingsPage() { Council Mode - Enable multi-model review for quality assurance. A second AI model reviews generated content. + Enable multi-model review for quality assurance. Multiple generators can compete, and a reviewer picks the best output. @@ -326,7 +359,7 @@ export function AiSettingsPage() {

- When enabled, generated questions are reviewed by a second AI model + When enabled, generators run in parallel and a reviewer selects the best result

-
-
- - handleChange('council_generator_model', e.target.value)} - placeholder="anthropic/claude-3.5-sonnet" - list="generation-models" - /> -

- Model used to generate content (overrides model_generate in council mode) -

+ + {/* Generators Section */} +
+
+
+ +

+ Models that generate content in parallel. The reviewer picks the best output. +

+
+
-
- - handleChange('council_reviewer_model', e.target.value)} - placeholder="google/gemini-pro-1.5" - list="review-models" - /> -

- Model used to review and score generated content -

+
+ {generators.map((gen, index) => ( +
+
+ {index + 1} +
+ updateGeneratorModel(index, e.target.value)} + placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Enter model ID..."} + list="generation-models" + className="flex-1" + /> + +
+ ))}
+ + {generators.length > 1 && ( +

+ All {generators.length} generators will run in parallel. The reviewer will compare all outputs and select the best one. +

+ )} +
+ + + + {/* Reviewer Section */} +
+ + handleChange('council_reviewer_model', e.target.value)} + placeholder="google/gemini-pro-1.5" + list="review-models" + /> +

+ Model used to review, score, and select the best generated content +

@@ -381,8 +456,8 @@ export function AiSettingsPage() { Usage Note - Council mode uses two AI calls per generation, which increases API costs. - It's recommended to use different model providers to get diverse perspectives. + Council mode uses {generators.length + 1} AI call{generators.length + 1 > 1 ? 's' : ''} per generation ({generators.length} generator{generators.length > 1 ? 's' : ''} + 1 reviewer), which increases API costs. + Using different model providers gives diverse perspectives. diff --git a/apps/supabase/migrations/20260101000001_add_council_generators_array.sql b/apps/supabase/migrations/20260101000001_add_council_generators_array.sql new file mode 100644 index 0000000..22bfffd --- /dev/null +++ b/apps/supabase/migrations/20260101000001_add_council_generators_array.sql @@ -0,0 +1,21 @@ +-- Add council_generators array field for multiple generator support +-- This allows users to configure multiple generators with different models +-- The reviewer will pick the best generation from all generators + +-- Add the new column (migrate existing single generator model to array format) +ALTER TABLE ai_config +ADD COLUMN IF NOT EXISTS council_generators jsonb; + +-- Set default value based on existing council_generator_model or default +UPDATE ai_config +SET council_generators = jsonb_build_array( + jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet')) +) +WHERE council_generators IS NULL; + +-- Set the default for new rows +ALTER TABLE ai_config +ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb; + +-- Add comment for documentation +COMMENT ON COLUMN ai_config.council_generators IS 'Array of generator configurations. Each object has a "model" field. Default is one generator with Claude 3.5 Sonnet.';