mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
f5f350b7f2
commit
a066300ed4
14 files changed
+1376
-61
No files matched your search
@@ -11,3 +11,10 @@ VITE_AI_MODEL=openai/gpt-4o-mini
|
||||
# VITE_AI_MODEL_GENERATE - for pack/question/category generation
|
||||
# VITE_AI_MODEL_FIX - for fix targets and fix text analysis
|
||||
# VITE_AI_MODEL_POLISH - for polishing/tidying up text
|
||||
|
||||
# AI Council Configuration (multi-model review system)
|
||||
# Set VITE_COUNCIL_ENABLED=true to enable multi-model review for question generation
|
||||
# When enabled, a second model reviews generated questions for quality
|
||||
VITE_COUNCIL_ENABLED=false
|
||||
VITE_COUNCIL_GENERATOR_MODEL=anthropic/claude-3.5-sonnet
|
||||
VITE_COUNCIL_REVIEWER_MODEL=google/gemini-pro-1.5
|
||||
@@ -20,6 +20,9 @@ ARG VITE_AI_MODEL
|
||||
ARG VITE_AI_MODEL_GENERATE
|
||||
ARG VITE_AI_MODEL_FIX
|
||||
ARG VITE_AI_MODEL_POLISH
|
||||
ARG VITE_COUNCIL_ENABLED
|
||||
ARG VITE_COUNCIL_GENERATOR_MODEL
|
||||
ARG VITE_COUNCIL_REVIEWER_MODEL
|
||||
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
@@ -29,6 +32,9 @@ ENV VITE_AI_MODEL=$VITE_AI_MODEL
|
||||
ENV VITE_AI_MODEL_GENERATE=$VITE_AI_MODEL_GENERATE
|
||||
ENV VITE_AI_MODEL_FIX=$VITE_AI_MODEL_FIX
|
||||
ENV VITE_AI_MODEL_POLISH=$VITE_AI_MODEL_POLISH
|
||||
ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED
|
||||
ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL
|
||||
ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL
|
||||
|
||||
RUN npx turbo run build --filter=@sauci/admin
|
||||
|
||||
|
||||
@@ -12,4 +12,7 @@ services:
|
||||
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
|
||||
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
|
||||
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
|
||||
- VITE_COUNCIL_ENABLED=${VITE_COUNCIL_ENABLED}
|
||||
- VITE_COUNCIL_GENERATOR_MODEL=${VITE_COUNCIL_GENERATOR_MODEL}
|
||||
- VITE_COUNCIL_REVIEWER_MODEL=${VITE_COUNCIL_REVIEWER_MODEL}
|
||||
restart: unless-stopped
|
||||
@@ -14,6 +14,9 @@ services:
|
||||
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
|
||||
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
|
||||
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
|
||||
- VITE_COUNCIL_ENABLED=${VITE_COUNCIL_ENABLED}
|
||||
- VITE_COUNCIL_GENERATOR_MODEL=${VITE_COUNCIL_GENERATOR_MODEL}
|
||||
- VITE_COUNCIL_REVIEWER_MODEL=${VITE_COUNCIL_REVIEWER_MODEL}
|
||||
volumes:
|
||||
- ./src:/app/apps/admin/src
|
||||
- ./index.html:/app/apps/admin/index.html
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sauci Admin</title>
|
||||
</head>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -128,6 +129,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ai-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<AiSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all - redirect to dashboard */}
|
||||
|
||||
@@ -12,8 +12,23 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square } from 'lucide-react';
|
||||
import { generatePack, generateQuestions, suggestCategories, suggestPacks, TONE_LEVELS, INTENSITY_LEVELS, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea, type ToneLevel } from '@/lib/openai';
|
||||
import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square, AlertTriangle, XCircle, CheckCircle2, Filter } from 'lucide-react';
|
||||
import {
|
||||
generatePack,
|
||||
generateQuestionsWithCouncil,
|
||||
suggestCategories,
|
||||
suggestPacks,
|
||||
getCouncilConfig,
|
||||
TONE_LEVELS,
|
||||
INTENSITY_LEVELS,
|
||||
type GeneratedPack,
|
||||
type GeneratedQuestion,
|
||||
type GeneratedCategoryIdea,
|
||||
type GeneratedPackIdea,
|
||||
type ToneLevel,
|
||||
type QuestionReview,
|
||||
type CouncilGenerationResult,
|
||||
} from '@/lib/openai';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -41,6 +56,7 @@ export function AiGeneratorDialog({
|
||||
onGenerated,
|
||||
}: AiGeneratorDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingStep, setLoadingStep] = useState<'generating' | 'reviewing' | null>(null);
|
||||
const [count, setCount] = useState(10);
|
||||
// Default tone based on pack's explicit flag (5 for explicit packs, 3 for non-explicit)
|
||||
const [tone, setTone] = useState<ToneLevel>(context?.isExplicit ? 5 : 3);
|
||||
@@ -61,11 +77,46 @@ export function AiGeneratorDialog({
|
||||
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
|
||||
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
|
||||
|
||||
// Council review state
|
||||
const [reviews, setReviews] = useState<QuestionReview[]>([]);
|
||||
const [reviewSummary, setReviewSummary] = useState<CouncilGenerationResult['summary']>(null);
|
||||
const [reviewMetadata, setReviewMetadata] = useState<CouncilGenerationResult['metadata'] | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'pass' | 'flag' | 'reject'>('all');
|
||||
|
||||
// Selection state for questions
|
||||
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState<Set<number>>(new Set());
|
||||
|
||||
// Get council config
|
||||
const councilConfig = getCouncilConfig();
|
||||
|
||||
// Helper to get review for a question index
|
||||
const getReviewForIndex = (index: number): QuestionReview | undefined => {
|
||||
if (!reviews || reviews.length === 0) return undefined;
|
||||
return reviews.find(r => r.index === index);
|
||||
};
|
||||
|
||||
// Filter questions based on current filter
|
||||
const getFilteredQuestionIndices = (): number[] => {
|
||||
if (!generatedQuestions || generatedQuestions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (filter === 'all' || !reviews || reviews.length === 0) {
|
||||
return generatedQuestions.map((_, i) => i);
|
||||
}
|
||||
return reviews
|
||||
.filter(r => r.verdict === filter)
|
||||
.map(r => r.index);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
setLoadingStep(null);
|
||||
// Reset council state
|
||||
setReviews([]);
|
||||
setReviewSummary(null);
|
||||
setReviewMetadata(null);
|
||||
setFilter('all');
|
||||
|
||||
try {
|
||||
if (type === 'pack') {
|
||||
const result = await generatePack(context?.categoryName, isExplicit, crudeLang, inspiration || undefined);
|
||||
@@ -75,10 +126,55 @@ export function AiGeneratorDialog({
|
||||
toast.error('Pack name is required');
|
||||
return;
|
||||
}
|
||||
const result = await generateQuestions(context.packName, count, undefined, tone, context.packDescription || undefined, context.existingQuestions, crudeLang, inspiration || undefined);
|
||||
setGeneratedQuestions(result);
|
||||
// Select all questions by default
|
||||
setSelectedQuestionIndices(new Set(result.map((_, i) => i)));
|
||||
|
||||
// Use council approach
|
||||
setLoadingStep('generating');
|
||||
const result = await generateQuestionsWithCouncil(
|
||||
context.packName,
|
||||
count,
|
||||
undefined,
|
||||
tone,
|
||||
context.packDescription || undefined,
|
||||
context.existingQuestions,
|
||||
crudeLang,
|
||||
inspiration || undefined
|
||||
);
|
||||
|
||||
const questions = result.questions || [];
|
||||
const reviewList = result.reviews || [];
|
||||
|
||||
setGeneratedQuestions(questions);
|
||||
setReviews(reviewList);
|
||||
setReviewSummary(result.summary);
|
||||
setReviewMetadata(result.metadata);
|
||||
|
||||
// Smart selection based on review results
|
||||
if (reviewList.length > 0) {
|
||||
// Select passed and flagged questions by default, not rejected
|
||||
const selectedIndices = new Set<number>();
|
||||
reviewList.forEach(r => {
|
||||
if (r.verdict === 'pass' || r.verdict === 'flag') {
|
||||
selectedIndices.add(r.index);
|
||||
}
|
||||
});
|
||||
setSelectedQuestionIndices(selectedIndices);
|
||||
|
||||
// Show toast with summary
|
||||
if (result.summary) {
|
||||
const { passed, flagged, rejected } = result.summary;
|
||||
if (rejected > 0) {
|
||||
toast.warning(`Review complete: ${passed} passed, ${flagged} flagged, ${rejected} rejected`);
|
||||
} else {
|
||||
toast.success(`Review complete: ${passed} passed, ${flagged} flagged`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No review (council disabled or failed) - select all
|
||||
setSelectedQuestionIndices(new Set(questions.map((_, i) => i)));
|
||||
if (councilConfig.enabled && !result.metadata?.reviewerModel) {
|
||||
toast.warning('Council review failed - showing unreviewed questions');
|
||||
}
|
||||
}
|
||||
} else if (type === 'category-ideas') {
|
||||
const result = await suggestCategories(context?.existingCategories || [], isExplicit, crudeLang, inspiration || undefined);
|
||||
setGeneratedIdeas(result);
|
||||
@@ -92,9 +188,10 @@ export function AiGeneratorDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI generation error:', error);
|
||||
toast.error('Failed to generate content. Check your OpenAI API key.');
|
||||
toast.error('Failed to generate content. Check your API key.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingStep(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,6 +238,11 @@ export function AiGeneratorDialog({
|
||||
setSelectedQuestionIndices(new Set());
|
||||
setCrudeLang(false);
|
||||
setInspiration('');
|
||||
// Reset council state
|
||||
setReviews([]);
|
||||
setReviewSummary(null);
|
||||
setReviewMetadata(null);
|
||||
setFilter('all');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -261,19 +363,30 @@ export function AiGeneratorDialog({
|
||||
|
||||
{/* Generate button */}
|
||||
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
|
||||
<Button onClick={handleGenerate} disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate with AI
|
||||
</>
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleGenerate} disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{loadingStep === 'generating' && councilConfig.enabled
|
||||
? 'Step 1/2: Generating...'
|
||||
: loadingStep === 'reviewing'
|
||||
? 'Step 2/2: Reviewing...'
|
||||
: 'Generating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate with AI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{councilConfig.enabled && type === 'questions' && !loading && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Council mode enabled - questions will be reviewed by a second model
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated Pack Preview */}
|
||||
@@ -290,6 +403,37 @@ export function AiGeneratorDialog({
|
||||
{/* Generated Questions Preview */}
|
||||
{generatedQuestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* Review Summary Bar */}
|
||||
{reviewSummary && (
|
||||
<div className="rounded-lg border bg-muted/50 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{reviewSummary.passed} Passed
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{reviewSummary.flagged} Flagged
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<XCircle className="h-4 w-4" />
|
||||
{reviewSummary.rejected} Rejected
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Quality: {reviewSummary.overallQuality}/10
|
||||
</span>
|
||||
</div>
|
||||
{reviewMetadata && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generated in {(reviewMetadata.generationTime / 1000).toFixed(1)}s
|
||||
{reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
|
||||
@@ -317,41 +461,136 @@ export function AiGeneratorDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{generatedQuestions.map((q, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
|
||||
selectedQuestionIndices.has(i)
|
||||
? 'border-primary ring-1 ring-primary/20'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => toggleQuestionSelection(i)}
|
||||
|
||||
{/* Filter Buttons */}
|
||||
{reviews && reviews.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedQuestionIndices.has(i)}
|
||||
onCheckedChange={() => toggleQuestionSelection(i)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p>{q.text}</p>
|
||||
{q.partner_text && (
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Partner: {q.partner_text}
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
className={`inline-block mt-1 text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
|
||||
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
|
||||
>
|
||||
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
|
||||
</span>
|
||||
<Filter className="mr-1 h-3 w-3" />
|
||||
All ({generatedQuestions.length})
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'pass' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('pass')}
|
||||
className={filter === 'pass' ? '' : 'text-green-600 hover:text-green-700'}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Passed ({reviewSummary?.passed || 0})
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'flag' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('flag')}
|
||||
className={filter === 'flag' ? '' : 'text-yellow-600 hover:text-yellow-700'}
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
Flagged ({reviewSummary?.flagged || 0})
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'reject' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('reject')}
|
||||
className={filter === 'reject' ? '' : 'text-red-600 hover:text-red-700'}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Rejected ({reviewSummary?.rejected || 0})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{getFilteredQuestionIndices().map((i) => {
|
||||
const q = generatedQuestions[i];
|
||||
const review = getReviewForIndex(i);
|
||||
const isRejected = review?.verdict === 'reject';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
|
||||
isRejected
|
||||
? 'opacity-50 border-red-300 bg-red-50/50 dark:bg-red-950/20'
|
||||
: selectedQuestionIndices.has(i)
|
||||
? 'border-primary ring-1 ring-primary/20'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => toggleQuestionSelection(i)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedQuestionIndices.has(i)}
|
||||
onCheckedChange={() => toggleQuestionSelection(i)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<p>{q.text}</p>
|
||||
{q.partner_text && (
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Partner: {q.partner_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`inline-block text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
|
||||
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
|
||||
>
|
||||
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
|
||||
</span>
|
||||
|
||||
{/* Review Verdict Badge */}
|
||||
{review && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
|
||||
review.verdict === 'pass'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: review.verdict === 'flag'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}
|
||||
title={review.scores ? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}/${review.scores.intensityAccuracy}` : ''}
|
||||
>
|
||||
{review.verdict === 'pass' && <CheckCircle2 className="h-3 w-3" />}
|
||||
{review.verdict === 'flag' && <AlertTriangle className="h-3 w-3" />}
|
||||
{review.verdict === 'reject' && <XCircle className="h-3 w-3" />}
|
||||
{review.verdict.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review Issues/Suggestions */}
|
||||
{review && ((review.issues && review.issues.length > 0) || review.suggestions) && (
|
||||
<div className="text-xs space-y-1 pt-1 border-t border-dashed">
|
||||
{review.issues && review.issues.length > 0 && (
|
||||
<ul className="list-disc list-inside text-muted-foreground">
|
||||
{review.issues.map((issue, idx) => (
|
||||
<li key={idx}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{review.suggestions && (
|
||||
<p className="text-blue-600 dark:text-blue-400">
|
||||
Suggestion: {review.suggestions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Ticket,
|
||||
MessageSquareText,
|
||||
Target,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -63,6 +64,12 @@ const systemNav: NavItem[] = [
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
|
||||
},
|
||||
{
|
||||
label: 'AI Settings',
|
||||
href: '/ai-settings',
|
||||
icon: <Bot className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
||||
},
|
||||
{
|
||||
label: 'Redemption Codes',
|
||||
href: '/redemption-codes',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { supabase } from '../config';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
|
||||
|
||||
// Define admin roles
|
||||
export type AdminRole = 'pack_creator' | 'super_admin';
|
||||
@@ -20,6 +21,7 @@ export const PERMISSION_KEYS = {
|
||||
// System permissions
|
||||
MANAGE_CODES: 'manage_codes',
|
||||
MANAGE_ADMINS: 'manage_admins',
|
||||
MANAGE_AI_CONFIG: 'manage_ai_config',
|
||||
VIEW_AUDIT_LOGS: 'view_audit_logs',
|
||||
} as const;
|
||||
|
||||
@@ -37,6 +39,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
|
||||
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
||||
[PERMISSION_KEYS.VIEW_AUDIT_LOGS]: { label: 'Can view audit logs', description: 'Access audit log history', group: 'system' },
|
||||
};
|
||||
|
||||
@@ -77,6 +80,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
|
||||
if (storedPermissions) {
|
||||
setPermissions(JSON.parse(storedPermissions));
|
||||
// Preload AI config if user has an existing session (non-blocking)
|
||||
preloadAiConfig().catch(console.error);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -128,11 +133,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(perms));
|
||||
setPermissions(perms);
|
||||
|
||||
// Preload AI config after successful login (non-blocking)
|
||||
preloadAiConfig().catch(console.error);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
localStorage.removeItem(PERMISSIONS_KEY);
|
||||
setPermissions(null);
|
||||
clearAiConfigCache();
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
|
||||
export interface AiConfig {
|
||||
id: string;
|
||||
openrouter_api_key: string | null;
|
||||
default_model: string | null;
|
||||
model_generate: string | null;
|
||||
model_fix: string | null;
|
||||
model_polish: string | null;
|
||||
council_enabled: boolean;
|
||||
council_generator_model: string | null;
|
||||
council_reviewer_model: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
interface UseAiConfigReturn {
|
||||
config: AiConfig | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
updateConfig: (updates: Partial<AiConfig>) => Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
// Cache for the config to avoid refetching on every use
|
||||
let cachedConfig: AiConfig | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export function useAiConfig(): UseAiConfigReturn {
|
||||
const [config, setConfig] = useState<AiConfig | null>(cachedConfig);
|
||||
const [loading, setLoading] = useState(!cachedConfig);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
// Use cache if valid
|
||||
if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) {
|
||||
setConfig(cachedConfig);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchError) {
|
||||
// If user doesn't have access (not super admin), that's expected
|
||||
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) {
|
||||
setError('Access denied. Super admin privileges required.');
|
||||
} else {
|
||||
setError(fetchError.message);
|
||||
}
|
||||
setConfig(null);
|
||||
} else if (data) {
|
||||
cachedConfig = data as AiConfig;
|
||||
cacheTimestamp = Date.now();
|
||||
setConfig(data as AiConfig);
|
||||
} else {
|
||||
// No config row exists yet
|
||||
setConfig(null);
|
||||
setError('AI config not initialized. Please contact support.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch AI config');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateConfig = useCallback(async (updates: Partial<AiConfig>): Promise<{ error: string | null }> => {
|
||||
if (!config?.id) {
|
||||
return { error: 'No config to update' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
.from('ai_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
|
||||
if (updateError) {
|
||||
return { error: updateError.message };
|
||||
}
|
||||
|
||||
// Invalidate cache and refetch
|
||||
cachedConfig = null;
|
||||
cacheTimestamp = 0;
|
||||
await fetchConfig();
|
||||
|
||||
return { error: null };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Failed to update AI config' };
|
||||
}
|
||||
}, [config?.id, fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchConfig,
|
||||
updateConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached AI config synchronously (for use in openai.ts)
|
||||
* Returns null if not yet loaded
|
||||
*/
|
||||
export function getCachedAiConfig(): AiConfig | null {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the AI config (call this on app init)
|
||||
*/
|
||||
export async function preloadAiConfig(): Promise<AiConfig | null> {
|
||||
if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (data) {
|
||||
cachedConfig = data as AiConfig;
|
||||
cacheTimestamp = Date.now();
|
||||
return cachedConfig;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - we'll fall back to env vars
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the config cache (useful when logging out)
|
||||
*/
|
||||
export function clearAiConfigCache(): void {
|
||||
cachedConfig = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
@@ -1,52 +1,149 @@
|
||||
import OpenAI from 'openai';
|
||||
import { getCachedAiConfig, preloadAiConfig, type AiConfig } from '@/hooks/useAiConfig';
|
||||
|
||||
// Note: In production, this should be called from a backend/edge function
|
||||
// to avoid exposing the API key
|
||||
|
||||
// Cache for the OpenAI instance to avoid recreating on every call
|
||||
let cachedOpenAI: OpenAI | null = null;
|
||||
let cachedApiKey: string | null = null;
|
||||
|
||||
const getOpenAI = () => {
|
||||
const openRouterKey = import.meta.env.VITE_OPENROUTER_API_KEY;
|
||||
// First, try to get API key from remote config
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
const remoteKey = remoteConfig?.openrouter_api_key;
|
||||
|
||||
// Fall back to env vars if remote config not available
|
||||
const openRouterKey = remoteKey || import.meta.env.VITE_OPENROUTER_API_KEY;
|
||||
const openAIKey = import.meta.env.VITE_OPENAI_API_KEY;
|
||||
|
||||
// Check if we can reuse cached instance
|
||||
if (cachedOpenAI && cachedApiKey === openRouterKey) {
|
||||
return cachedOpenAI;
|
||||
}
|
||||
|
||||
if (openRouterKey) {
|
||||
return new OpenAI({
|
||||
cachedOpenAI = new OpenAI({
|
||||
apiKey: openRouterKey,
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
cachedApiKey = openRouterKey;
|
||||
return cachedOpenAI;
|
||||
}
|
||||
|
||||
if (!openAIKey) {
|
||||
throw new Error('VITE_OPENAI_API_KEY or VITE_OPENROUTER_API_KEY is missing');
|
||||
throw new Error('OpenRouter API key not configured. Please configure it in AI Settings or set VITE_OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
cachedOpenAI = new OpenAI({
|
||||
apiKey: openAIKey,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
cachedApiKey = openAIKey;
|
||||
return cachedOpenAI;
|
||||
};
|
||||
|
||||
type ModelPurpose = 'generate' | 'fix' | 'polish';
|
||||
|
||||
const getModel = (purpose?: ModelPurpose) => {
|
||||
// First, try to get model from remote config
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
|
||||
// Granular model selection by purpose
|
||||
// Falls back to VITE_AI_MODEL, then to default
|
||||
// Priority: remote config > env vars > default
|
||||
const defaultModel = 'gpt-4o-mini';
|
||||
const fallback = import.meta.env.VITE_AI_MODEL || defaultModel;
|
||||
|
||||
// Get fallback model (remote config default > env var > hardcoded default)
|
||||
const fallback = remoteConfig?.default_model || import.meta.env.VITE_AI_MODEL || defaultModel;
|
||||
|
||||
if (!purpose) return fallback;
|
||||
|
||||
switch (purpose) {
|
||||
case 'generate':
|
||||
return import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
|
||||
return remoteConfig?.model_generate || import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
|
||||
case 'fix':
|
||||
return import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
||||
return remoteConfig?.model_fix || import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
||||
case 'polish':
|
||||
return import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
||||
return remoteConfig?.model_polish || import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// COUNCIL CONFIGURATION - Multi-model review system
|
||||
// =============================================================================
|
||||
|
||||
export interface CouncilConfig {
|
||||
enabled: boolean;
|
||||
generatorModel: string;
|
||||
reviewerModel: string;
|
||||
}
|
||||
|
||||
export function getCouncilConfig(): CouncilConfig {
|
||||
// First, try to get config from remote
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
|
||||
if (remoteConfig) {
|
||||
return {
|
||||
enabled: remoteConfig.council_enabled || false,
|
||||
generatorModel: remoteConfig.council_generator_model || 'anthropic/claude-3.5-sonnet',
|
||||
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
|
||||
};
|
||||
}
|
||||
|
||||
// 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',
|
||||
reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize AI config by preloading from Supabase
|
||||
* Call this on app startup to ensure config is available
|
||||
*/
|
||||
export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
return preloadAiConfig();
|
||||
}
|
||||
|
||||
export interface QuestionReview {
|
||||
index: number;
|
||||
verdict: 'pass' | 'flag' | 'reject';
|
||||
issues: string[];
|
||||
suggestions?: string;
|
||||
scores: {
|
||||
guidelineCompliance: number;
|
||||
creativity: number;
|
||||
clarity: number;
|
||||
intensityAccuracy: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
reviews: QuestionReview[];
|
||||
summary: {
|
||||
passed: number;
|
||||
flagged: number;
|
||||
rejected: number;
|
||||
overallQuality: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CouncilGenerationResult {
|
||||
questions: GeneratedQuestion[];
|
||||
reviews: QuestionReview[];
|
||||
summary: ReviewResult['summary'] | null;
|
||||
metadata: {
|
||||
generatorModel: string;
|
||||
reviewerModel: string | null;
|
||||
generationTime: number;
|
||||
reviewTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeneratedPack {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -313,6 +410,12 @@ ANATOMICAL CONSISTENCY RULE:
|
||||
- GOOD: "Give your partner a handjob" (single activity for target anatomy)
|
||||
- GOOD: "Go down on your partner" (gender-neutral oral phrasing)
|
||||
|
||||
TIMELESS LANGUAGE RULE:
|
||||
- NEVER use time-specific words like "tonight", "now", "today", "this evening", "right now"
|
||||
- Activities are proposals that couples may do at ANY time in the future
|
||||
- BAD: "Give your partner a massage tonight", "Text your partner right now"
|
||||
- GOOD: "Give your partner a massage", "Send your partner a flirty text"
|
||||
|
||||
For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other):
|
||||
|
||||
1. SYMMETRIC Activities (partner_text = null):
|
||||
@@ -1093,3 +1196,247 @@ ${JSON.stringify(simplifiedQuestions)}
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.suggestions || [];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COUNCIL REVIEW FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
const REVIEW_GUIDELINES = `
|
||||
REVIEW CRITERIA - Score each 1-10:
|
||||
|
||||
1. GUIDELINE COMPLIANCE:
|
||||
- Uses "your partner" (not "me", "I", "you", "him", "her")
|
||||
- Card is a proposal, not a question ("Have dinner" not "Would you want to have dinner?")
|
||||
- No wishy-washy language ("Would you...", "Have you ever...", "Do you think...")
|
||||
- No time-specific words ("tonight", "now", "today", "right now") - activities should be timeless
|
||||
- Appropriate length (5-12 words ideal)
|
||||
|
||||
2. CREATIVITY:
|
||||
- Not cliche (avoid: candlelit dinner, rose petals, bubble bath, Netflix and chill)
|
||||
- Specific and engaging (not generic)
|
||||
- Good variety in sentence openers
|
||||
|
||||
3. CLARITY:
|
||||
- Clear, actionable proposal
|
||||
- Partner text (if present) clearly describes receiver's experience
|
||||
- No confusing or ambiguous phrasing
|
||||
|
||||
4. INTENSITY ACCURACY:
|
||||
Level 1 (Light): Talking, emotional sharing, compliments, non-sexual
|
||||
Level 2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
|
||||
Level 3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
|
||||
Level 4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting
|
||||
Level 5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
|
||||
|
||||
5. ANATOMICAL CONSISTENCY:
|
||||
- No mixed male/female anatomy in alternatives
|
||||
- BAD: "Finger or give your partner a handjob"
|
||||
- GOOD: Pick one activity
|
||||
|
||||
6. PARTNER TEXT QUALITY (if applicable):
|
||||
- Engaging and enticing, not clinical
|
||||
- BAD: "Receive oral from your partner"
|
||||
- GOOD: "Let your partner pleasure you with their mouth"
|
||||
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
||||
`;
|
||||
|
||||
export async function reviewGeneratedQuestions(
|
||||
questions: GeneratedQuestion[],
|
||||
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel }
|
||||
): Promise<ReviewResult> {
|
||||
const openai = getOpenAI();
|
||||
const config = getCouncilConfig();
|
||||
|
||||
// Null safety check
|
||||
if (!questions || questions.length === 0) {
|
||||
return {
|
||||
reviews: [],
|
||||
summary: { passed: 0, flagged: 0, rejected: 0, overallQuality: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const questionsForReview = questions.map((q, i) => ({
|
||||
index: i,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
location_type: q.location_type,
|
||||
effort_level: q.effort_level,
|
||||
}));
|
||||
|
||||
const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown';
|
||||
|
||||
const prompt = `You are a quality reviewer for a couples intimacy app. Review each generated question against our guidelines.
|
||||
|
||||
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}
|
||||
|
||||
VERDICT RULES:
|
||||
- PASS: All scores >= 7, no major issues
|
||||
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
||||
- REJECT: Any score < 5, or major violations (mixed anatomy, wrong intensity, severe guideline violation)
|
||||
|
||||
QUESTIONS TO REVIEW:
|
||||
${JSON.stringify(questionsForReview, null, 2)}
|
||||
|
||||
Return a JSON object with:
|
||||
{
|
||||
"reviews": [
|
||||
{
|
||||
"index": 0,
|
||||
"verdict": "pass" | "flag" | "reject",
|
||||
"issues": ["Issue 1", "Issue 2"],
|
||||
"suggestions": "Optional improvement suggestion",
|
||||
"scores": {
|
||||
"guidelineCompliance": 8,
|
||||
"creativity": 7,
|
||||
"clarity": 9,
|
||||
"intensityAccuracy": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Review ALL questions. Be thorough but fair - only flag/reject for genuine issues.`;
|
||||
|
||||
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 evaluate generated questions for guideline compliance, creativity, clarity, and accuracy. 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 review content generated');
|
||||
|
||||
// Strip markdown code blocks if present (some models ignore response_format)
|
||||
let jsonContent = content.trim();
|
||||
if (jsonContent.startsWith('```')) {
|
||||
// Remove opening ```json or ``` and closing ```
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
const reviews: QuestionReview[] = parsed.reviews || [];
|
||||
|
||||
// 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;
|
||||
|
||||
// Calculate overall quality as average of all scores
|
||||
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 {
|
||||
reviews,
|
||||
summary: {
|
||||
passed,
|
||||
flagged,
|
||||
rejected,
|
||||
overallQuality,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuestionsWithCouncil(
|
||||
packName: string,
|
||||
count: number = 10,
|
||||
intensity?: number,
|
||||
tone: ToneLevel = 3,
|
||||
packDescription?: string,
|
||||
existingQuestions?: string[],
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string
|
||||
): Promise<CouncilGenerationResult> {
|
||||
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 (!config.enabled) {
|
||||
return {
|
||||
questions,
|
||||
reviews: [],
|
||||
summary: null,
|
||||
metadata: {
|
||||
generatorModel: getModel('generate'),
|
||||
reviewerModel: null,
|
||||
generationTime: genTime,
|
||||
reviewTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const startReview = Date.now();
|
||||
const isExplicit = tone >= 4;
|
||||
|
||||
try {
|
||||
const reviewResult = await reviewGeneratedQuestions(questions, {
|
||||
name: packName,
|
||||
description: packDescription,
|
||||
isExplicit,
|
||||
tone,
|
||||
});
|
||||
const reviewTime = Date.now() - startReview;
|
||||
|
||||
return {
|
||||
questions,
|
||||
reviews: reviewResult.reviews,
|
||||
summary: reviewResult.summary,
|
||||
metadata: {
|
||||
generatorModel: config.generatorModel,
|
||||
reviewerModel: config.reviewerModel,
|
||||
generationTime: genTime,
|
||||
reviewTime,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If review fails, return questions without review (with warning in metadata)
|
||||
console.error('Council review failed:', error);
|
||||
return {
|
||||
questions,
|
||||
reviews: [],
|
||||
summary: null,
|
||||
metadata: {
|
||||
generatorModel: config.generatorModel,
|
||||
reviewerModel: null, // null indicates review failed
|
||||
generationTime: genTime,
|
||||
reviewTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAiConfig, AiConfig } from '@/hooks/useAiConfig';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// Popular OpenRouter models for suggestions
|
||||
const POPULAR_MODELS = {
|
||||
generation: [
|
||||
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
|
||||
{ id: 'meta-llama/llama-3.1-405b-instruct', name: 'Llama 3.1 405B' },
|
||||
],
|
||||
review: [
|
||||
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
|
||||
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus' },
|
||||
],
|
||||
};
|
||||
|
||||
export function AiSettingsPage() {
|
||||
const { hasPermission, isSuperAdmin } = useAuth();
|
||||
const { config, loading, error, updateConfig, refetch } = useAiConfig();
|
||||
|
||||
// Local form state
|
||||
const [formData, setFormData] = useState<Partial<AiConfig>>({});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
|
||||
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
|
||||
|
||||
// Initialize form data when config loads
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
openrouter_api_key: config.openrouter_api_key || '',
|
||||
default_model: config.default_model || '',
|
||||
model_generate: config.model_generate || '',
|
||||
model_fix: config.model_fix || '',
|
||||
model_polish: config.model_polish || '',
|
||||
council_enabled: config.council_enabled || false,
|
||||
council_generator_model: config.council_generator_model || '',
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleChange = <K extends keyof AiConfig>(field: K, value: AiConfig[K]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const { error: saveError } = await updateConfig(formData);
|
||||
if (saveError) {
|
||||
toast.error(saveError);
|
||||
} else {
|
||||
toast.success('AI settings saved successfully');
|
||||
setHasChanges(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
openrouter_api_key: config.openrouter_api_key || '',
|
||||
default_model: config.default_model || '',
|
||||
model_generate: config.model_generate || '',
|
||||
model_fix: config.model_fix || '',
|
||||
model_polish: config.model_polish || '',
|
||||
council_enabled: config.council_enabled || false,
|
||||
council_generator_model: config.council_generator_model || '',
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[50vh]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
You don't have permission to manage AI settings. Contact a super admin for access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI Settings</h1>
|
||||
<p className="text-muted-foreground">Configure AI models and API settings</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI Settings</h1>
|
||||
<p className="text-muted-foreground">Configure AI models and API settings</p>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error loading settings</AlertTitle>
|
||||
<AlertDescription className="flex items-center gap-4">
|
||||
{error}
|
||||
<Button variant="outline" size="sm" onClick={refetch}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure AI models and API settings for content generation
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<Button variant="ghost" onClick={handleReset}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config?.updated_at && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last updated: {format(new Date(config.updated_at), 'MMM d, yyyy HH:mm')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* API Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
API Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your OpenRouter API key for AI model access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">OpenRouter API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="api-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={formData.openrouter_api_key || ''}
|
||||
onChange={(e) => handleChange('openrouter_api_key', e.target.value)}
|
||||
placeholder="sk-or-v1-..."
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
Model Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure which AI models to use for different tasks. Leave blank to use the default model.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-model">Default Model</Label>
|
||||
<Input
|
||||
id="default-model"
|
||||
value={formData.default_model || ''}
|
||||
onChange={(e) => handleChange('default_model', e.target.value)}
|
||||
placeholder="openai/gpt-4o-mini"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fallback model when no specific model is configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Generation Model</Label>
|
||||
<Input
|
||||
id="model-generate"
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(e) => handleChange('model_generate', e.target.value)}
|
||||
placeholder="Uses default"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For generating questions & content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-fix">Analysis Model</Label>
|
||||
<Input
|
||||
id="model-fix"
|
||||
value={formData.model_fix || ''}
|
||||
onChange={(e) => handleChange('model_fix', e.target.value)}
|
||||
placeholder="Uses default"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For analyzing & fixing content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-polish">Polish Model</Label>
|
||||
<Input
|
||||
id="model-polish"
|
||||
value={formData.model_polish || ''}
|
||||
onChange={(e) => handleChange('model_polish', e.target.value)}
|
||||
placeholder="Uses default"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For polishing & improving text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datalist for model suggestions */}
|
||||
<datalist id="generation-models">
|
||||
{POPULAR_MODELS.generation.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Council Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Council Mode
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enable multi-model review for quality assurance. A second AI model reviews generated content.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="council-enabled">Enable Council Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, generated questions are reviewed by a second AI model
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="council-enabled"
|
||||
checked={formData.council_enabled || false}
|
||||
onCheckedChange={(checked: boolean) => handleChange('council_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.council_enabled && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="council-generator">Generator Model</Label>
|
||||
<Input
|
||||
id="council-generator"
|
||||
value={formData.council_generator_model || ''}
|
||||
onChange={(e) => handleChange('council_generator_model', e.target.value)}
|
||||
placeholder="anthropic/claude-3.5-sonnet"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used to generate content (overrides model_generate in council mode)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="council-reviewer">Reviewer Model</Label>
|
||||
<Input
|
||||
id="council-reviewer"
|
||||
value={formData.council_reviewer_model || ''}
|
||||
onChange={(e) => handleChange('council_reviewer_model', e.target.value)}
|
||||
placeholder="google/gemini-pro-1.5"
|
||||
list="review-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used to review and score generated content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<datalist id="review-models">
|
||||
{POPULAR_MODELS.review.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Usage Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
Council mode uses two AI calls per generation, which increases API costs.
|
||||
It's recommended to use different model providers to get diverse perspectives.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
-- AI Configuration table for admin portal
|
||||
-- Stores AI settings that can be configured by super admins
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_config (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- API Configuration
|
||||
openrouter_api_key text,
|
||||
|
||||
-- Model Configuration
|
||||
default_model text DEFAULT 'openai/gpt-4o-mini',
|
||||
model_generate text,
|
||||
model_fix text,
|
||||
model_polish text,
|
||||
|
||||
-- Council Configuration
|
||||
council_enabled boolean DEFAULT false,
|
||||
council_generator_model text DEFAULT 'anthropic/claude-3.5-sonnet',
|
||||
council_reviewer_model text DEFAULT 'google/gemini-pro-1.5',
|
||||
|
||||
-- Metadata
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
updated_by uuid REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Only allow one row in this table (singleton pattern)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true));
|
||||
|
||||
-- Insert default config row
|
||||
INSERT INTO ai_config (id) VALUES (gen_random_uuid())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Only super admins can read/write (checked via admin_users table)
|
||||
CREATE POLICY "Super admins can read ai_config"
|
||||
ON ai_config FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'super_admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Super admins can update ai_config"
|
||||
ON ai_config FOR UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'super_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Create function to update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_ai_config_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
NEW.updated_by = auth.uid();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create trigger to auto-update timestamp
|
||||
DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config;
|
||||
CREATE TRIGGER ai_config_updated_at
|
||||
BEFORE UPDATE ON ai_config
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_ai_config_timestamp();
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.';
|
||||
Reference in new issue
Block a user