mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add MCP server and enhance AI content tools
Implement self-hosted MCP server for admin actions, update AI generation tools in admin dashboard, and refine mobile app stores and components.
This commit is contained in:
1 parent
b0f1700e40
commit
4e40b92b16
71 files changed
+10511
-1008
No files matched your search
@@ -7,6 +7,8 @@ import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||
import { PacksPage } from '@/pages/content/PacksPage';
|
||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||
import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
|
||||
import { TagsPage } from '@/pages/content/TagsPage';
|
||||
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
||||
import { DaresPage } from '@/pages/content/DaresPage';
|
||||
import { UsersPage } from '@/pages/users/UsersPage';
|
||||
@@ -66,6 +68,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/question-analytics"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuestionAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tags"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<TagsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dare-packs"
|
||||
element={
|
||||
|
||||
@@ -53,10 +53,10 @@ export function GenerationForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wildness Level Selector (questions only) */}
|
||||
{/* Intimacy Level Selector (questions only) */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Wildness Level</Label>
|
||||
<Label>Intimacy Level</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TONE_LEVELS.map((t) => (
|
||||
<Button
|
||||
|
||||
@@ -77,7 +77,7 @@ export function useAiGeneration(
|
||||
// Config state
|
||||
const [config, setConfig] = useState<GenerationConfig>({
|
||||
count: 10,
|
||||
tone: context?.isExplicit ? 5 : 3,
|
||||
tone: context?.isExplicit ? 5 : 2,
|
||||
crudeLang: false,
|
||||
inspiration: '',
|
||||
});
|
||||
@@ -123,7 +123,7 @@ export function useAiGeneration(
|
||||
if (type === 'questions') {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
tone: context?.isExplicit ? 5 : 3,
|
||||
tone: context?.isExplicit ? 5 : 2,
|
||||
}));
|
||||
}
|
||||
}, [context?.isExplicit, type]);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -14,6 +13,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Loader2, X } from 'lucide-react';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
|
||||
@@ -26,6 +32,11 @@ interface Topic {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PackFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -33,6 +44,7 @@ export interface PackFormData {
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
category_id: string;
|
||||
}
|
||||
|
||||
export interface PackFormDialogProps {
|
||||
@@ -43,6 +55,7 @@ export interface PackFormDialogProps {
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
isEditing: boolean;
|
||||
categories: Category[];
|
||||
allTopics: Topic[];
|
||||
selectedTopicIds: Set<string>;
|
||||
onTopicsChange: (topicIds: Set<string>) => void;
|
||||
@@ -60,6 +73,7 @@ export function PackFormDialog({
|
||||
onSave,
|
||||
saving,
|
||||
isEditing,
|
||||
categories,
|
||||
allTopics,
|
||||
selectedTopicIds,
|
||||
onTopicsChange,
|
||||
@@ -141,6 +155,27 @@ export function PackFormDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<Label>Category (optional)</Label>
|
||||
<Select
|
||||
value={formData.category_id || '__none__'}
|
||||
onValueChange={(value) => setField('category_id', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No category</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Premium Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -11,8 +11,18 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck } from 'lucide-react';
|
||||
import { analyzeQuestionText, analyzeQuestionTargets, TextAnalysis, TargetAnalysis } from '@/lib/openai';
|
||||
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck, Gauge, Trash2, Package } from 'lucide-react';
|
||||
import {
|
||||
analyzeQuestionText,
|
||||
analyzeQuestionTargets,
|
||||
analyzeQuestionDeletions,
|
||||
analyzeQuestionProps,
|
||||
TextAnalysis,
|
||||
TargetAnalysis,
|
||||
DeletionAnalysis,
|
||||
PropsAnalysis,
|
||||
} from '@/lib/openai';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -23,6 +33,7 @@ interface Question {
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[] | null;
|
||||
target_user_genders: string[] | null;
|
||||
required_props?: string[] | null;
|
||||
}
|
||||
|
||||
interface ReviewQuestionsDialogProps {
|
||||
@@ -38,13 +49,19 @@ interface CombinedSuggestion {
|
||||
question: Question;
|
||||
textSuggestion?: TextAnalysis;
|
||||
targetSuggestion?: TargetAnalysis;
|
||||
propsSuggestion?: PropsAnalysis;
|
||||
deleteSuggestion?: DeletionAnalysis;
|
||||
}
|
||||
|
||||
export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: ReviewQuestionsDialogProps) {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<CombinedSuggestion[]>([]);
|
||||
const [existingProps, setExistingProps] = useState<string[]>([]);
|
||||
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
|
||||
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(new Set());
|
||||
const [selectedPropsIds, setSelectedPropsIds] = useState<Set<string>>(new Set());
|
||||
const [selectedIntensityIds, setSelectedIntensityIds] = useState<Set<string>>(new Set());
|
||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState<Set<string>>(new Set());
|
||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
@@ -71,14 +88,55 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
return sortedA.every((val, i) => val === sortedB[i]);
|
||||
};
|
||||
|
||||
const loadExistingProps = async (): Promise<string[]> => {
|
||||
if (existingProps.length > 0) return existingProps;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('required_props')
|
||||
.not('required_props', 'is', null)
|
||||
.limit(5000);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const next = new Set<string>();
|
||||
data?.forEach(row => {
|
||||
row.required_props?.forEach((prop: string) => next.add(prop));
|
||||
});
|
||||
|
||||
const list = Array.from(next).sort((a, b) => a.localeCompare(b));
|
||||
setExistingProps(list);
|
||||
return list;
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing props:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
// Run both analyses in parallel
|
||||
const [textResults, targetResults] = await Promise.all([
|
||||
analyzeQuestionText(questions, isExplicit),
|
||||
analyzeQuestionTargets(questions)
|
||||
]);
|
||||
const propsCatalog = await loadExistingProps();
|
||||
|
||||
// Run analyses in parallel (pass intensity for text analysis)
|
||||
const [textResults, targetResults, deleteResults, propsResults] = await Promise.all([
|
||||
analyzeQuestionText(
|
||||
questions.map(q => ({ ...q, intensity: q.intensity })),
|
||||
isExplicit
|
||||
),
|
||||
analyzeQuestionTargets(questions),
|
||||
analyzeQuestionDeletions(questions, isExplicit),
|
||||
analyzeQuestionProps(
|
||||
questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
required_props: q.required_props || null,
|
||||
})),
|
||||
propsCatalog
|
||||
),
|
||||
]) as [TextAnalysis[], TargetAnalysis[], DeletionAnalysis[], PropsAnalysis[]];
|
||||
|
||||
// Filter target results to only meaningful changes
|
||||
const meaningfulTargetResults = targetResults.filter(suggestion => {
|
||||
@@ -98,26 +156,35 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
return coupleChanged || initiatorChanged;
|
||||
});
|
||||
|
||||
// Combine results by question ID
|
||||
const questionIds = new Set([
|
||||
...textResults.map(t => t.id),
|
||||
...meaningfulTargetResults.map(t => t.id)
|
||||
]);
|
||||
const meaningfulPropsResults = propsResults.filter(suggestion => {
|
||||
const question = questions.find(q => q.id === suggestion.id);
|
||||
if (!question) return false;
|
||||
|
||||
const combined: CombinedSuggestion[] = Array.from(questionIds).map(id => {
|
||||
const question = questions.find(q => q.id === id)!;
|
||||
return {
|
||||
questionId: id,
|
||||
question,
|
||||
textSuggestion: textResults.find(t => t.id === id),
|
||||
targetSuggestion: meaningfulTargetResults.find(t => t.id === id),
|
||||
};
|
||||
return !arraysEqual(
|
||||
question.required_props || null,
|
||||
suggestion.suggested_required_props || null
|
||||
);
|
||||
});
|
||||
|
||||
const combined: CombinedSuggestion[] = questions.map(question => ({
|
||||
questionId: question.id,
|
||||
question,
|
||||
textSuggestion: textResults.find(t => t.id === question.id),
|
||||
targetSuggestion: meaningfulTargetResults.find(t => t.id === question.id),
|
||||
propsSuggestion: meaningfulPropsResults.find(p => p.id === question.id),
|
||||
deleteSuggestion: deleteResults.find(d => d.id === question.id),
|
||||
}));
|
||||
|
||||
setSuggestions(combined);
|
||||
// Default select all
|
||||
setSelectedTextIds(new Set(textResults.map(r => r.id)));
|
||||
setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id)));
|
||||
setSelectedPropsIds(new Set(meaningfulPropsResults.map(r => r.id)));
|
||||
setSelectedDeleteIds(new Set(deleteResults.map(r => r.id)));
|
||||
// Select intensity suggestions by default
|
||||
setSelectedIntensityIds(new Set(
|
||||
textResults.filter(r => r.suggested_intensity != null).map(r => r.id)
|
||||
));
|
||||
setStep('review');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -131,13 +198,19 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
setApplying(true);
|
||||
try {
|
||||
const updates: { id: string; data: Record<string, unknown> }[] = [];
|
||||
const deleteIds = Array.from(selectedDeleteIds);
|
||||
|
||||
// Build update objects combining text and target changes
|
||||
// Build update objects combining text, target, and intensity changes
|
||||
suggestions.forEach(suggestion => {
|
||||
if (selectedDeleteIds.has(suggestion.questionId)) return;
|
||||
|
||||
const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion;
|
||||
const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion;
|
||||
const propsSelected = selectedPropsIds.has(suggestion.questionId) && suggestion.propsSuggestion;
|
||||
const intensitySelected = selectedIntensityIds.has(suggestion.questionId) &&
|
||||
suggestion.textSuggestion?.suggested_intensity != null;
|
||||
|
||||
if (textSelected || targetSelected) {
|
||||
if (textSelected || targetSelected || propsSelected || intensitySelected) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (textSelected && suggestion.textSuggestion) {
|
||||
@@ -156,23 +229,49 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
: null;
|
||||
}
|
||||
|
||||
if (propsSelected && suggestion.propsSuggestion) {
|
||||
updateData.required_props = suggestion.propsSuggestion.suggested_required_props &&
|
||||
suggestion.propsSuggestion.suggested_required_props.length > 0
|
||||
? suggestion.propsSuggestion.suggested_required_props
|
||||
: null;
|
||||
}
|
||||
|
||||
if (intensitySelected && suggestion.textSuggestion?.suggested_intensity != null) {
|
||||
updateData.intensity = suggestion.textSuggestion.suggested_intensity;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
updates.push({ id: suggestion.questionId, data: updateData });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.length === 0) {
|
||||
if (updates.length === 0 && deleteIds.length === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply updates in parallel with audit logging
|
||||
await Promise.all(updates.map(update =>
|
||||
// Apply updates/deletions in parallel with audit logging
|
||||
const operations: Promise<unknown>[] = updates.map(update =>
|
||||
auditedSupabase.update('questions', update.id, update.data)
|
||||
));
|
||||
);
|
||||
|
||||
toast.success(`Updated ${updates.length} questions`);
|
||||
if (deleteIds.length > 0) {
|
||||
operations.push(
|
||||
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
|
||||
if (result.error) throw result.error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
const updateMessage = updates.length > 0 ? `Updated ${updates.length} questions` : null;
|
||||
const deleteMessage = deleteIds.length > 0 ? `Deleted ${deleteIds.length} questions` : null;
|
||||
const message = [updateMessage, deleteMessage].filter(Boolean).join('. ');
|
||||
if (message) {
|
||||
toast.success(message);
|
||||
}
|
||||
onUpdated();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
@@ -190,6 +289,9 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
setSuggestions([]);
|
||||
setSelectedTextIds(new Set());
|
||||
setSelectedTargetIds(new Set());
|
||||
setSelectedPropsIds(new Set());
|
||||
setSelectedIntensityIds(new Set());
|
||||
setSelectedDeleteIds(new Set());
|
||||
}, 300);
|
||||
};
|
||||
|
||||
@@ -207,6 +309,27 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
setSelectedTargetIds(next);
|
||||
};
|
||||
|
||||
const togglePropsSelection = (id: string) => {
|
||||
const next = new Set(selectedPropsIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedPropsIds(next);
|
||||
};
|
||||
|
||||
const toggleIntensitySelection = (id: string) => {
|
||||
const next = new Set(selectedIntensityIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIntensityIds(next);
|
||||
};
|
||||
|
||||
const toggleDeleteSelection = (id: string) => {
|
||||
const next = new Set(selectedDeleteIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedDeleteIds(next);
|
||||
};
|
||||
|
||||
const formatTargets = (targets: string[] | null) => {
|
||||
if (!targets || targets.length === 0) return 'All';
|
||||
return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', ');
|
||||
@@ -217,35 +340,89 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', ');
|
||||
};
|
||||
|
||||
const formatProps = (props: string[] | null | undefined) => {
|
||||
if (!props || props.length === 0) return 'None';
|
||||
return props.join(', ');
|
||||
};
|
||||
|
||||
const formatDeleteCategory = (category?: DeletionAnalysis['category']) => {
|
||||
switch (category) {
|
||||
case 'duplicate':
|
||||
return 'Duplicate';
|
||||
case 'redundant':
|
||||
return 'Redundant';
|
||||
case 'off-tone':
|
||||
return 'Off tone';
|
||||
case 'unsafe':
|
||||
return 'Unsafe';
|
||||
case 'too-vague':
|
||||
return 'Too vague';
|
||||
case 'broken':
|
||||
return 'Broken';
|
||||
case 'off-topic':
|
||||
return 'Off topic';
|
||||
default:
|
||||
return 'Delete';
|
||||
}
|
||||
};
|
||||
|
||||
const getQuestionTextById = (id?: string | null) => {
|
||||
if (!id) return null;
|
||||
const match = questions.find(q => q.id === id);
|
||||
return match ? match.text : null;
|
||||
};
|
||||
|
||||
// Count suggestions by type
|
||||
const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length;
|
||||
const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length;
|
||||
const totalSelectedCount = selectedTextIds.size + selectedTargetIds.size;
|
||||
const propsSuggestionCount = suggestions.filter(s => s.propsSuggestion).length;
|
||||
const intensitySuggestionCount = suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).length;
|
||||
const deleteSuggestionCount = suggestions.filter(s => s.deleteSuggestion).length;
|
||||
const effectiveTextCount = Array.from(selectedTextIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||
const effectiveTargetCount = Array.from(selectedTargetIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||
const effectivePropsCount = Array.from(selectedPropsIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||
const effectiveIntensityCount = Array.from(selectedIntensityIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||
const totalSelectedCount = effectiveTextCount + effectiveTargetCount + effectivePropsCount + effectiveIntensityCount + selectedDeleteIds.size;
|
||||
const hasSuggestions = textSuggestionCount > 0 || targetSuggestionCount > 0 || propsSuggestionCount > 0 || intensitySuggestionCount > 0 || deleteSuggestionCount > 0;
|
||||
|
||||
// Select all/none helpers
|
||||
const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId)));
|
||||
const selectNoneText = () => setSelectedTextIds(new Set());
|
||||
const selectAllTargets = () => setSelectedTargetIds(new Set(suggestions.filter(s => s.targetSuggestion).map(s => s.questionId)));
|
||||
const selectNoneTargets = () => setSelectedTargetIds(new Set());
|
||||
const selectAllProps = () => setSelectedPropsIds(new Set(suggestions.filter(s => s.propsSuggestion).map(s => s.questionId)));
|
||||
const selectNoneProps = () => setSelectedPropsIds(new Set());
|
||||
const selectAllIntensity = () => setSelectedIntensityIds(new Set(
|
||||
suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).map(s => s.questionId)
|
||||
));
|
||||
const selectNoneIntensity = () => setSelectedIntensityIds(new Set());
|
||||
const selectAllDeletes = () => setSelectedDeleteIds(new Set(suggestions.filter(s => s.deleteSuggestion).map(s => s.questionId)));
|
||||
const selectNoneDeletes = () => setSelectedDeleteIds(new Set());
|
||||
const selectAll = () => {
|
||||
selectAllText();
|
||||
selectAllTargets();
|
||||
selectAllProps();
|
||||
selectAllIntensity();
|
||||
selectAllDeletes();
|
||||
};
|
||||
const selectNone = () => {
|
||||
selectNoneText();
|
||||
selectNoneTargets();
|
||||
selectNoneProps();
|
||||
selectNoneIntensity();
|
||||
selectNoneDeletes();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
Review Questions
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
AI-powered analysis to improve text phrasing and fix audience targeting.
|
||||
AI-powered analysis to improve phrasing, identify props, fix targeting, and suggest deletions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -258,7 +435,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
|
||||
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||
The AI will analyze all questions and suggest improvements for:
|
||||
The AI will analyze all questions and suggest improvements or removals for:
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
@@ -270,6 +447,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
<ShieldCheck className="h-5 w-5 text-indigo-400" />
|
||||
<span className="text-sm font-medium text-indigo-300">Audience Targeting</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-cyan-500/20 px-4 py-2 rounded-lg">
|
||||
<Package className="h-5 w-5 text-cyan-400" />
|
||||
<span className="text-sm font-medium text-cyan-300">Required Props</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-amber-500/20 px-4 py-2 rounded-lg">
|
||||
<Gauge className="h-5 w-5 text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-300">Intensity Level</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-rose-500/20 px-4 py-2 rounded-lg">
|
||||
<Trash2 className="h-5 w-5 text-rose-400" />
|
||||
<span className="text-sm font-medium text-rose-300">Deletion Suggestions</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
|
||||
{analyzing ? (
|
||||
@@ -284,7 +473,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggestions.length === 0 ? (
|
||||
{!hasSuggestions ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
||||
All questions look great! No improvements needed.
|
||||
@@ -302,6 +491,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
<ShieldCheck className="h-4 w-4 text-indigo-500" />
|
||||
{targetSuggestionCount} target fixes
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="h-4 w-4 text-cyan-500" />
|
||||
{propsSuggestionCount} props fixes
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Gauge className="h-4 w-4 text-amber-500" />
|
||||
{intensitySuggestionCount} intensity fixes
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||
{deleteSuggestionCount} delete suggestions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||
@@ -313,12 +514,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[300px]">Question</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[18%] min-w-[180px]">Question</TableHead>
|
||||
<TableHead className="w-[26%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 text-purple-500" />
|
||||
Text Fix
|
||||
@@ -332,7 +533,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</Button>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[18%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-indigo-500" />
|
||||
Target Fix
|
||||
@@ -346,32 +547,78 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</Button>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[18%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-cyan-500" />
|
||||
Props
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => selectedPropsIds.size === propsSuggestionCount ? selectNoneProps() : selectAllProps()}
|
||||
>
|
||||
{selectedPropsIds.size === propsSuggestionCount ? 'None' : 'All'}
|
||||
</Button>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[10%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-4 w-4 text-amber-500" />
|
||||
Intensity
|
||||
{intensitySuggestionCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => selectedIntensityIds.size === intensitySuggestionCount ? selectNoneIntensity() : selectAllIntensity()}
|
||||
>
|
||||
{selectedIntensityIds.size === intensitySuggestionCount ? 'None' : 'All'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[10%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||
Delete
|
||||
{deleteSuggestionCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => selectedDeleteIds.size === deleteSuggestionCount ? selectNoneDeletes() : selectAllDeletes()}
|
||||
>
|
||||
{selectedDeleteIds.size === deleteSuggestionCount ? 'None' : 'All'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{suggestions.map((suggestion) => (
|
||||
<TableRow key={suggestion.questionId}>
|
||||
{/* Question Column */}
|
||||
<TableCell className="align-top">
|
||||
<div className="text-sm font-medium line-clamp-2">{suggestion.question.text}</div>
|
||||
<TableCell className="align-top py-3">
|
||||
<div className="text-sm font-medium">{suggestion.question.text}</div>
|
||||
{suggestion.question.partner_text && (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic line-clamp-1">
|
||||
<div className="text-xs text-muted-foreground mt-1.5 italic">
|
||||
Partner: {suggestion.question.partner_text}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Text Fix Column */}
|
||||
<TableCell className="align-top">
|
||||
<TableCell className="align-top py-3">
|
||||
{suggestion.textSuggestion ? (
|
||||
<div
|
||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
|
||||
? 'bg-purple-500/20 border-purple-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => toggleTextSelection(suggestion.questionId)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedTextIds.has(suggestion.questionId)}
|
||||
onCheckedChange={() => toggleTextSelection(suggestion.questionId)}
|
||||
@@ -382,11 +629,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{suggestion.textSuggestion.suggested_text}
|
||||
</div>
|
||||
{suggestion.textSuggestion.suggested_partner_text && (
|
||||
<div className="text-xs text-purple-400 mt-1 italic">
|
||||
<div className="text-xs text-purple-400 mt-1.5 italic">
|
||||
Partner: {suggestion.textSuggestion.suggested_partner_text}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{suggestion.textSuggestion.reason}
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,16 +648,16 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</TableCell>
|
||||
|
||||
{/* Target Fix Column */}
|
||||
<TableCell className="align-top">
|
||||
<TableCell className="align-top py-3">
|
||||
{suggestion.targetSuggestion ? (
|
||||
<div
|
||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
|
||||
? 'bg-indigo-500/20 border-indigo-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => toggleTargetSelection(suggestion.questionId)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedTargetIds.has(suggestion.questionId)}
|
||||
onCheckedChange={() => toggleTargetSelection(suggestion.questionId)}
|
||||
@@ -418,26 +665,28 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Couple targets */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Couples:</span>
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{formatTargets(suggestion.question.allowed_couple_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge className="text-[10px] bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
|
||||
<Badge className="text-xs bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
|
||||
{formatTargets(suggestion.targetSuggestion.suggested_targets)}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Initiator */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-500/50">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Initiator:</span>
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground border-orange-500/50">
|
||||
{formatInitiator(suggestion.question.target_user_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge className="text-[10px] bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
|
||||
<Badge className="text-xs bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
|
||||
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{suggestion.targetSuggestion.reason}
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,6 +699,130 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Props Column */}
|
||||
<TableCell className="align-top py-3">
|
||||
{suggestion.propsSuggestion ? (
|
||||
<div
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedPropsIds.has(suggestion.questionId)
|
||||
? 'bg-cyan-500/20 border-cyan-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => togglePropsSelection(suggestion.questionId)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedPropsIds.has(suggestion.questionId)}
|
||||
onCheckedChange={() => togglePropsSelection(suggestion.questionId)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Current:</span>
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{formatProps(suggestion.question.required_props)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge className="text-xs bg-cyan-500/30 text-cyan-300 hover:bg-cyan-500/30">
|
||||
{formatProps(suggestion.propsSuggestion.suggested_required_props)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{suggestion.propsSuggestion.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground p-2">
|
||||
<Check className="h-4 w-4 text-green-500 inline mr-1" />
|
||||
No changes needed
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Intensity Column */}
|
||||
<TableCell className="align-top py-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Current: {suggestion.question.intensity}
|
||||
</Badge>
|
||||
</div>
|
||||
{suggestion.textSuggestion?.suggested_intensity != null ? (
|
||||
<div
|
||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedIntensityIds.has(suggestion.questionId)
|
||||
? 'bg-amber-500/20 border-amber-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => toggleIntensitySelection(suggestion.questionId)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
checked={selectedIntensityIds.has(suggestion.questionId)}
|
||||
onCheckedChange={() => toggleIntensitySelection(suggestion.questionId)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge className="text-xs bg-amber-500/30 text-amber-300 hover:bg-amber-500/30">
|
||||
{suggestion.textSuggestion.suggested_intensity}
|
||||
</Badge>
|
||||
</div>
|
||||
{suggestion.textSuggestion.intensity_reason && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{suggestion.textSuggestion.intensity_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500 inline mr-1" />
|
||||
Correct
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Delete Column */}
|
||||
<TableCell className="align-top py-3">
|
||||
{suggestion.deleteSuggestion ? (
|
||||
<div
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedDeleteIds.has(suggestion.questionId)
|
||||
? 'bg-rose-500/20 border-rose-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => toggleDeleteSelection(suggestion.questionId)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedDeleteIds.has(suggestion.questionId)}
|
||||
onCheckedChange={() => toggleDeleteSelection(suggestion.questionId)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-rose-300 font-medium">
|
||||
{formatDeleteCategory(suggestion.deleteSuggestion.category)} recommended
|
||||
</div>
|
||||
{suggestion.deleteSuggestion.duplicate_of_id && (
|
||||
<div className="text-xs text-rose-400 mt-1">
|
||||
Duplicate of: {getQuestionTextById(suggestion.deleteSuggestion.duplicate_of_id) || 'Another question'}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{suggestion.deleteSuggestion.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground p-2">
|
||||
<Check className="h-4 w-4 text-green-500 inline mr-1" />
|
||||
Keep
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -478,7 +851,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
`Apply ${totalSelectedCount} Changes`
|
||||
`Apply ${totalSelectedCount} Actions`
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -109,6 +109,8 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
});
|
||||
} else if (segment === 'questions' && params.packId) {
|
||||
breadcrumbs.push({ label: 'Questions', href: currentPath });
|
||||
} else if (segment === 'tags') {
|
||||
breadcrumbs.push({ label: 'Tags', href: '/tags' });
|
||||
} else if (segment === 'users') {
|
||||
breadcrumbs.push({ label: 'Users', href: '/users' });
|
||||
// If we have a userId, add the user's name as a breadcrumb
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Home,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
PieChart,
|
||||
Tags,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -54,6 +56,18 @@ const contentNav: NavItem[] = [
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
||||
},
|
||||
{
|
||||
label: 'Question Analytics',
|
||||
href: '/question-analytics',
|
||||
icon: <PieChart className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS,
|
||||
},
|
||||
{
|
||||
label: 'Tags',
|
||||
href: '/tags',
|
||||
icon: <Tags className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
||||
},
|
||||
];
|
||||
|
||||
const usersNav: NavItem[] = [
|
||||
|
||||
@@ -3,15 +3,16 @@ import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ className, checked, onCheckedChange, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { supabase } from '@/config';
|
||||
|
||||
export interface CouncilGenerator {
|
||||
model: string;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
|
||||
@@ -11,17 +12,23 @@ export interface AiConfig {
|
||||
id: string;
|
||||
openrouter_api_key: string | null;
|
||||
default_model: string | null;
|
||||
default_temperature: number | null;
|
||||
model_generate: string | null;
|
||||
temperature_generate: number | null;
|
||||
model_fix: string | null;
|
||||
temperature_fix: number | null;
|
||||
model_polish: string | null;
|
||||
temperature_polish: number | null;
|
||||
council_enabled: boolean;
|
||||
council_generator_model: string | null; // Legacy - kept for backwards compatibility
|
||||
council_generators: CouncilGenerator[] | null; // New array of generators
|
||||
council_reviewer_model: string | null;
|
||||
council_reviewer_temperature: number | null;
|
||||
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
|
||||
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
|
||||
classifier_enabled: boolean | null;
|
||||
classifier_model: string | null;
|
||||
classifier_temperature: number | null;
|
||||
classifier_prompt: string | null;
|
||||
heuristics_enabled: boolean | null;
|
||||
heuristic_min_text_length: number | null;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type SupabaseFilterFn<T> = (query: ReturnType<typeof supabase.from>) => ReturnType<typeof supabase.from>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SupabaseFilterFn = (query: any) => any;
|
||||
|
||||
interface UseCrudOperationsOptions<T> {
|
||||
/**
|
||||
@@ -89,15 +90,16 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
* @param filter Optional filter function to modify the query
|
||||
*/
|
||||
const fetchAll = useCallback(async (
|
||||
filter?: SupabaseFilterFn<T>
|
||||
filter?: SupabaseFilterFn
|
||||
): Promise<T[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let query = supabase.from(table).select(select);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let query: any = supabase.from(table).select(select);
|
||||
|
||||
if (filter) {
|
||||
query = filter(query) as typeof query;
|
||||
query = filter(query);
|
||||
}
|
||||
|
||||
if (orderBy) {
|
||||
@@ -155,7 +157,8 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
): Promise<T | null> => {
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
const { data: created, error: createError } = await auditedSupabase.insert(table, data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
@@ -167,7 +170,8 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
return created as T;
|
||||
// auditedSupabase.insert returns T[] | null, get first element
|
||||
return created && created.length > 0 ? (created[0] as T) : null;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
toast.error(defaultMessages.createError);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// =============================================================================
|
||||
// Deletion Analysis Functions
|
||||
// Suggest removals for low-quality or duplicate questions
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { DeletionAnalysis } from '../types';
|
||||
|
||||
/**
|
||||
* Analyze questions and suggest deletions (duplicates, unsafe, etc.)
|
||||
*/
|
||||
export async function analyzeQuestionDeletions(
|
||||
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
|
||||
isExplicit: boolean = false
|
||||
): Promise<DeletionAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const simplifiedQuestions = questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
intensity: q.intensity,
|
||||
}));
|
||||
|
||||
const toneInstruction = isExplicit
|
||||
? 'CONTENT: EXPLICIT pack. Adult content is allowed but must be consensual, safe, and clear. Avoid coercion, minors, illegal acts, or unsafe suggestions.'
|
||||
: 'CONTENT: NON-EXPLICIT pack. Do NOT include explicit sexual acts, crude language, or NSFW content. If the core action is sexual, it should be removed, not rewritten.';
|
||||
|
||||
const prompt = `<task>
|
||||
Identify questions that should be DELETED instead of edited.
|
||||
Only include questions that are better removed than fixed.
|
||||
</task>
|
||||
|
||||
<content_type>
|
||||
${toneInstruction}
|
||||
</content_type>
|
||||
|
||||
<deletion_categories>
|
||||
- duplicate: same core action as another question (near-duplicate)
|
||||
- redundant: minor variation with no added value
|
||||
- off-tone: violates explicit vs non-explicit rules
|
||||
- unsafe: coercion, minors, illegal acts, or consent violations
|
||||
- too-vague: not actionable or not a clear proposal
|
||||
- broken: grammar/structure too broken to repair without inventing content
|
||||
- off-topic: not relevant to a couples intimacy app
|
||||
</deletion_categories>
|
||||
|
||||
<rules>
|
||||
1. Prefer edits over deletions when possible.
|
||||
2. Duplicates: delete the weaker/less specific version and keep the clearer one.
|
||||
3. If duplicate, set duplicate_of_id to the kept question's id.
|
||||
4. Do NOT delete for minor wording issues or intensity mismatches.
|
||||
</rules>
|
||||
|
||||
<questions_to_analyze>
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
</questions_to_analyze>
|
||||
|
||||
<output_format>
|
||||
{
|
||||
"deletions": [
|
||||
{
|
||||
"id": string,
|
||||
"category": "duplicate"|"redundant"|"off-tone"|"unsafe"|"too-vague"|"broken"|"off-topic",
|
||||
"reason": string,
|
||||
"duplicate_of_id": string|null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Only return questions that should be deleted.
|
||||
</output_format>`;
|
||||
|
||||
const systemMessage = isExplicit
|
||||
? 'You are a strict content quality reviewer for an adult couples app. Remove only what should be deleted. Always respond with valid JSON only.'
|
||||
: 'You are a strict content quality reviewer for a couples app. Remove only what should be deleted. Always respond with valid JSON only.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemMessage,
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: getTemperature('fix', 0.2),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
let jsonContent = content.trim();
|
||||
if (jsonContent.startsWith('```')) {
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
return parsed.deletions || [];
|
||||
}
|
||||
@@ -1,8 +1,53 @@
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
|
||||
export interface ImageSafetyAnalysis {
|
||||
category: 'SAFE' | 'ADULT_CONTENT' | 'VIOLENCE' | 'MINORS' | 'ILLEGAL' | 'UNCERTAIN';
|
||||
severity: 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
description: string;
|
||||
concerns: string[];
|
||||
isAppropriate: boolean;
|
||||
}
|
||||
|
||||
const SAFETY_ANALYSIS_PROMPT = `<task>
|
||||
Analyze this image for content safety in the context of an adult couples intimacy app.
|
||||
</task>
|
||||
|
||||
<context>
|
||||
This app is for ADULT couples (25-45) to explore intimacy. Adult content between consenting adults is expected and acceptable. The concern is inappropriate content like violence, minors, or illegal activity.
|
||||
</context>
|
||||
|
||||
<categories>
|
||||
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults)
|
||||
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review
|
||||
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
|
||||
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
|
||||
- ILLEGAL: Drug use, criminal activity, non-consensual content
|
||||
- UNCERTAIN: Cannot determine, needs human review
|
||||
</categories>
|
||||
|
||||
<severity_levels>
|
||||
- none: No safety concerns at all
|
||||
- low: Minor flag, likely fine but worth noting
|
||||
- medium: Should be reviewed by admin
|
||||
- high: Likely inappropriate, requires immediate review
|
||||
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
|
||||
</severity_levels>
|
||||
|
||||
<output_format>
|
||||
Return a JSON object:
|
||||
{
|
||||
"category": "SAFE"|"ADULT_CONTENT"|"VIOLENCE"|"MINORS"|"ILLEGAL"|"UNCERTAIN",
|
||||
"severity": "none"|"low"|"medium"|"high"|"critical",
|
||||
"description": "Brief factual description of what's shown (max 50 words)",
|
||||
"concerns": ["List of specific concerns if any, empty array if none"],
|
||||
"isAppropriate": true|false // false for VIOLENCE, MINORS, ILLEGAL or high/critical severity
|
||||
}
|
||||
</output_format>`;
|
||||
|
||||
export async function describeImage(imageBase64OrUrl: string): Promise<string> {
|
||||
const openai = getOpenAI();
|
||||
const model = getModel('describe_image');
|
||||
const temperature = getTemperature('describe_image', 0.0); // Deterministic for safety
|
||||
|
||||
// If it's a data URL or http URL, use it as is.
|
||||
// If it's raw base64, prepend the data URI scheme.
|
||||
@@ -18,7 +63,7 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe this image in detail. Focus on any potentially inappropriate content or safety concerns.' },
|
||||
{ type: 'text', text: SAFETY_ANALYSIS_PROMPT },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
@@ -29,6 +74,7 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
|
||||
},
|
||||
],
|
||||
max_tokens: 300,
|
||||
temperature: temperature,
|
||||
});
|
||||
|
||||
return response.choices[0].message.content || 'No description available.';
|
||||
@@ -37,3 +83,28 @@ export async function describeImage(imageBase64OrUrl: string): Promise<string> {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze image safety and return structured result
|
||||
*/
|
||||
export async function analyzeImageSafety(imageBase64OrUrl: string): Promise<ImageSafetyAnalysis> {
|
||||
const rawResult = await describeImage(imageBase64OrUrl);
|
||||
|
||||
try {
|
||||
// Try to parse as JSON
|
||||
let jsonContent = rawResult.trim();
|
||||
if (jsonContent.startsWith('```')) {
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
return JSON.parse(jsonContent) as ImageSafetyAnalysis;
|
||||
} catch {
|
||||
// If parsing fails, return a default "uncertain" result
|
||||
return {
|
||||
category: 'UNCERTAIN',
|
||||
severity: 'medium',
|
||||
description: rawResult.slice(0, 200),
|
||||
concerns: ['Could not parse safety analysis result'],
|
||||
isAppropriate: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// =============================================================================
|
||||
// Props Analysis Functions
|
||||
// Identify required props/accessories for questions
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { PropsAnalysis } from '../types';
|
||||
|
||||
/**
|
||||
* Analyze questions for required props/accessories
|
||||
*/
|
||||
export async function analyzeQuestionProps(
|
||||
questions: {
|
||||
id: string;
|
||||
text: string;
|
||||
partner_text?: string | null;
|
||||
required_props?: string[] | null;
|
||||
}[],
|
||||
existingProps: string[] = []
|
||||
): Promise<PropsAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const normalizedExistingProps = existingProps
|
||||
.map(prop => prop.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const simplifiedQuestions = questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
current_required_props: q.required_props ?? null,
|
||||
}));
|
||||
|
||||
const prompt = `<task>
|
||||
Identify any props/accessories required to perform each question.
|
||||
Only include questions where required props should change (missing, incorrect, or overly generic).
|
||||
</task>
|
||||
|
||||
<existing_props>
|
||||
Prefer these exact prop names when possible (reuse existing names instead of inventing new ones):
|
||||
${JSON.stringify(normalizedExistingProps)}
|
||||
</existing_props>
|
||||
|
||||
<rules>
|
||||
1. Props are physical items/accessories, not body parts or acts.
|
||||
2. Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator").
|
||||
3. If a prop is essentially the same as an existing one, reuse the existing name.
|
||||
4. Avoid making up new variants when a close existing name is available.
|
||||
5. Example: if "remote vibrator" exists, map "wifi" or "app-controlled" variants to "remote vibrator".
|
||||
6. Use specific items when clearly stated; otherwise allow "toy" or "props".
|
||||
7. If no props are required, set suggested_required_props to null.
|
||||
8. Only return entries where the suggested props differ from current_required_props.
|
||||
</rules>
|
||||
|
||||
<questions_to_analyze>
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
</questions_to_analyze>
|
||||
|
||||
<output_format>
|
||||
{
|
||||
"analysis": [
|
||||
{
|
||||
"id": string,
|
||||
"suggested_required_props": string[]|null,
|
||||
"reason": string
|
||||
}
|
||||
]
|
||||
}
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a content analyst for a couples app. Identify required props/accessories and return valid JSON.'
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: getTemperature('fix', 0.3),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.analysis || [];
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// Analyze questions for gender targeting and couple compatibility
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { TargetAnalysis } from '../types';
|
||||
|
||||
/**
|
||||
@@ -28,59 +28,67 @@ export async function analyzeQuestionTargets(
|
||||
current_initiator: q.target_user_genders
|
||||
}));
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest:
|
||||
1. "allowed_couple_genders" (which couple types can see this question)
|
||||
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
|
||||
const prompt = `<task>
|
||||
Analyze questions for couple targeting and initiator targeting.
|
||||
Only include questions where you have a recommendation to change current settings.
|
||||
</task>
|
||||
|
||||
COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
|
||||
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
|
||||
<targeting_options>
|
||||
Couple targets (allowed_couple_genders): 'male+male', 'female+male', 'female+female'
|
||||
Initiator targets (target_user_genders): 'male', 'female', 'non-binary'
|
||||
</targeting_options>
|
||||
|
||||
RULES FOR COUPLE TARGETS (allowed_couple_genders):
|
||||
- Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
|
||||
- Limit ONLY if there is a clear, explicit anatomical requirement (e.g. genitalia-specific terms).
|
||||
- SEX TOYS ARE GENDER-NEUTRAL: Vibrators, dildos, plugs, etc. can be used by ANYONE. Do NOT restrict based on toys.
|
||||
- "Control your partner's vibrator" -> ALL couples, ANY initiator (vibrators work on any body)
|
||||
- "Use a butt plug" -> ALL couples (everyone has a butt)
|
||||
<couple_targeting_rules>
|
||||
DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
|
||||
Examples of ACTUAL restrictions:
|
||||
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
|
||||
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
|
||||
- "Vibrator", "dildo", "toy" -> NO restriction, works for everyone
|
||||
Restrict ONLY when:
|
||||
- Activity requires penis -> ['male+male', 'female+male'] (exclude F+F)
|
||||
- Activity requires vagina -> ['female+male', 'female+female'] (exclude M+M)
|
||||
|
||||
RULES FOR INITIATOR (target_user_genders):
|
||||
- ONLY relevant for TWO-PART questions (those with partner_text set).
|
||||
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
|
||||
- ONLY set initiator if there is an EXPLICIT anatomical requirement in the text.
|
||||
- SEX TOYS DO NOT IMPLY GENDER: "vibrator", "dildo", "toy", "plug" -> initiator: null (anyone can use/control toys)
|
||||
- If text is gender-neutral, leave as null. When in doubt, use null.
|
||||
|
||||
Key considerations for initiator:
|
||||
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
|
||||
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
|
||||
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
|
||||
4. Is the action truly symmetric with no gendered context? -> null
|
||||
SEX TOYS ARE GENDER-NEUTRAL:
|
||||
- Vibrators, dildos, plugs work on ANYONE -> null (no restriction)
|
||||
- "Control your partner's vibrator" -> null
|
||||
- "Use a butt plug" -> null (everyone has a butt)
|
||||
|
||||
Examples:
|
||||
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
|
||||
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
|
||||
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
|
||||
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
|
||||
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
|
||||
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
|
||||
- "Blowjob" -> needs penis -> ['male+male', 'female+male']
|
||||
- "Lick pussy" -> needs vagina -> ['female+male', 'female+female']
|
||||
- "Use a toy" -> gender-neutral -> null
|
||||
</couple_targeting_rules>
|
||||
|
||||
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
|
||||
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
|
||||
<initiator_targeting_rules>
|
||||
ONLY for asymmetric questions (those with partner_text).
|
||||
DEFAULT: null (anyone can initiate)
|
||||
|
||||
Structure:
|
||||
Set initiator ONLY when "text" field has explicit anatomical requirement:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
|
||||
- "Lick my cum off your body" -> "my cum" = initiator has penis -> ['male']
|
||||
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
- "Let her ride you" -> "her" implies female partner -> ['male']
|
||||
- Sex toys -> null (anyone can use/control toys)
|
||||
|
||||
When in doubt, use null.
|
||||
</initiator_targeting_rules>
|
||||
|
||||
<questions_to_analyze>
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
</questions_to_analyze>
|
||||
|
||||
<output_format>
|
||||
{
|
||||
"analysis": [
|
||||
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
|
||||
]
|
||||
"analysis": [
|
||||
{
|
||||
"id": string,
|
||||
"suggested_targets": string[]|null, // e.g., ["male+male", "female+male"] or null
|
||||
"suggested_initiator": string[]|null, // e.g., ["male"] or null
|
||||
"reason": string // Explanation of recommendation
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
`;
|
||||
Only include questions where targeting should change.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
@@ -92,7 +100,7 @@ ${JSON.stringify(simplifiedQuestions)}
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.5,
|
||||
temperature: getTemperature('fix', 0.5),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
// Analyze and suggest improvements for question text
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { TextAnalysis } from '../types';
|
||||
|
||||
/**
|
||||
* Analyze question text and suggest improvements
|
||||
*/
|
||||
export async function analyzeQuestionText(
|
||||
questions: { id: string; text: string; partner_text?: string | null }[],
|
||||
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
|
||||
isExplicit: boolean = false
|
||||
): Promise<TextAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
@@ -19,6 +19,7 @@ export async function analyzeQuestionText(
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
intensity: q.intensity,
|
||||
}));
|
||||
|
||||
const toneInstruction = isExplicit
|
||||
@@ -38,56 +39,81 @@ export async function analyzeQuestionText(
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
|
||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
|
||||
const prompt = `<task>
|
||||
Analyze the following questions and suggest improved text that follows our style guidelines.
|
||||
Only include questions that NEED improvement - skip well-phrased ones.
|
||||
</task>
|
||||
|
||||
<content_type>
|
||||
${toneInstruction}
|
||||
</content_type>
|
||||
|
||||
<app_context>
|
||||
The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" relative to a specific action, NOT interview questions.
|
||||
Cards should be "Proposals" for specific actions, NOT interview questions.
|
||||
</app_context>
|
||||
|
||||
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
|
||||
|
||||
1. SYMMETRIC Activities (Single Card - partner_text should be null):
|
||||
- Use for shared experiences where both partners do the same thing together.
|
||||
- Phrasing: Direct action proposal.
|
||||
<question_types>
|
||||
1. SYMMETRIC Activities (partner_text = null):
|
||||
- Both partners do the same thing together
|
||||
- ${symmetricExamples}
|
||||
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
|
||||
- BAD: "Have you ever thought about...", "Do you think we should..."
|
||||
|
||||
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
|
||||
- Use for actions where one partner does something TO/FOR the other, or where roles differ.
|
||||
- text = what the INITIATOR does (their action/perspective)
|
||||
- partner_text = what the RECEIVER does/experiences (their action/perspective)
|
||||
2. ASYMMETRIC Actions (needs both text AND partner_text):
|
||||
- One partner does something TO/FOR the other
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER experiences
|
||||
- ${asymmetricExamples}
|
||||
- IMPORTANT: partner_text should describe the RECEIVER's experience clearly:
|
||||
- text: "Send your partner to the bathroom and follow a minute later"
|
||||
- partner_text: "Go to the bathroom and wait for your partner to join you" (NOT "Wait for your partner to follow you")
|
||||
- When initiator CAUSES a response (moan, cum, beg, etc.), partner_text should frame as ALLOWING/RECEIVING:
|
||||
- text: "Make your partner moan in public" -> partner_text: "Let your partner make you moan in public"
|
||||
- NOT: "Moan for your partner in public" (sounds like deliberate performance, not natural response)
|
||||
- BAD: "Would you like to...?" (Ambiguous who "me" is)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
|
||||
2. Keep text SHORT and DIRECT - no explanatory parentheticals. Aim for 5-12 words.
|
||||
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
|
||||
4. Make it direct and actionable.
|
||||
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
|
||||
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
|
||||
7. If a question is ALREADY well-phrased, do NOT include it in the results.
|
||||
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
|
||||
9. NEVER MIX ANATOMICALLY INCOMPATIBLE ACTIVITIES - Do NOT combine male-specific and female-specific acts as alternatives in the same question.
|
||||
10. MAKE PARTNER_TEXT APPEALING - Don't just grammatically flip the text. Make the receiver feel excited.
|
||||
11. FLAG CLICHES for improvement - "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill" are overused
|
||||
Partner text rules:
|
||||
- Describe RECEIVER's experience clearly
|
||||
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING:
|
||||
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
|
||||
* NOT: "Moan for your partner" (sounds forced, not natural)
|
||||
</question_types>
|
||||
|
||||
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
|
||||
Each object should have:
|
||||
- id: The question ID
|
||||
- suggested_text: The improved text (short, direct, no parentheticals)
|
||||
- suggested_partner_text: The improved partner_text (or null if symmetric)
|
||||
- reason: Brief explanation of what was improved
|
||||
<rules>
|
||||
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about
|
||||
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
|
||||
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
|
||||
4. Use "your partner" instead of "me", "you", "him", "her"
|
||||
5. NEVER mix anatomically incompatible activities in same question
|
||||
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
|
||||
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
|
||||
8. Skip questions that are ALREADY well-phrased
|
||||
</rules>
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}`;
|
||||
<intensity_guide>
|
||||
Check if current intensity matches the activity:
|
||||
1 (Gentle): Emotional bonding, non-sexual (cooking, cuddling, foot massage)
|
||||
2 (Warm): Romantic, affectionate touch (slow dance, sensual massage, kissing)
|
||||
3 (Playful): Light sexual exploration (oral, mutual masturbation, light roleplay)
|
||||
4 (Steamy): Explicit sex, moderate adventure (intercourse, light bondage, anal play)
|
||||
5 (Intense): Advanced/BDSM/extreme (impact play, power dynamics, taboo kinks)
|
||||
|
||||
If intensity seems wrong, include suggested_intensity and intensity_reason.
|
||||
</intensity_guide>
|
||||
|
||||
<questions_to_analyze>
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
</questions_to_analyze>
|
||||
|
||||
<output_format>
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": string, // Question ID
|
||||
"suggested_text": string, // Improved text (short, direct)
|
||||
"suggested_partner_text": string|null, // Improved partner_text or null if symmetric
|
||||
"reason": string, // Brief explanation of text improvement
|
||||
"suggested_intensity": number|null, // 1-5 if intensity seems wrong, null if correct
|
||||
"intensity_reason": string|null // Why intensity should change (if applicable)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Only include questions that need text improvement OR intensity adjustment.
|
||||
</output_format>`;
|
||||
|
||||
const systemMessage = isExplicit
|
||||
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
|
||||
@@ -103,7 +129,7 @@ ${JSON.stringify(simplifiedQuestions)}`;
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.5,
|
||||
temperature: getTemperature('fix', 0.5),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -78,6 +78,31 @@ export function getModel(purpose?: ModelPurpose): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate temperature for a given purpose
|
||||
* Uses remote config > default temperature > fallback
|
||||
*/
|
||||
export function getTemperature(purpose?: ModelPurpose, fallback?: number): number {
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
const defaultTemperature = remoteConfig?.default_temperature;
|
||||
const baseFallback = defaultTemperature ?? fallback ?? 0.7;
|
||||
|
||||
if (!purpose) return baseFallback;
|
||||
|
||||
switch (purpose) {
|
||||
case 'generate':
|
||||
return remoteConfig?.temperature_generate ?? defaultTemperature ?? fallback ?? 0.9;
|
||||
case 'fix':
|
||||
return remoteConfig?.temperature_fix ?? defaultTemperature ?? fallback ?? 0.5;
|
||||
case 'polish':
|
||||
return remoteConfig?.temperature_polish ?? defaultTemperature ?? fallback ?? 0.7;
|
||||
case 'describe_image':
|
||||
return remoteConfig?.classifier_temperature ?? defaultTemperature ?? fallback ?? 1.0;
|
||||
default:
|
||||
return baseFallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short display name from a model identifier
|
||||
* e.g., "anthropic/claude-3.5-sonnet" -> "Claude 3.5 Sonnet"
|
||||
|
||||
+116
-66
@@ -31,6 +31,7 @@ export function getCouncilConfig(): CouncilConfig {
|
||||
enabled: remoteConfig.council_enabled || false,
|
||||
generators,
|
||||
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
|
||||
reviewerTemperature: remoteConfig.council_reviewer_temperature ?? 0.3,
|
||||
selectionMode: remoteConfig.council_selection_mode || 'whole_set',
|
||||
cherryPickEnsureIntensityDistribution: remoteConfig.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
};
|
||||
@@ -41,6 +42,7 @@ export function getCouncilConfig(): CouncilConfig {
|
||||
enabled: import.meta.env.VITE_COUNCIL_ENABLED === 'true',
|
||||
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',
|
||||
reviewerTemperature: 0.3,
|
||||
selectionMode: 'whole_set',
|
||||
cherryPickEnsureIntensityDistribution: true,
|
||||
};
|
||||
@@ -60,12 +62,11 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
// =============================================================================
|
||||
|
||||
export const TONE_LEVELS = [
|
||||
{ level: 0, label: 'Everyday', description: 'Non-romantic activities - games, cooking, conversations' },
|
||||
{ level: 1, label: 'Sweet', description: 'Romantic but innocent - compliments, date nights, cuddles' },
|
||||
{ level: 2, label: 'Flirty', description: 'Playful teasing - flirty texts, light touching, innuendo' },
|
||||
{ level: 3, label: 'Intimate', description: 'Sensual but soft - massage, making out, showering together' },
|
||||
{ level: 4, label: 'Sexual', description: 'Standard sexual activities - oral, sex, toys' },
|
||||
{ level: 5, label: 'Wild', description: 'Extreme/kinky - public sex, creampies, BDSM, exhibitionism' },
|
||||
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
|
||||
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
|
||||
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' },
|
||||
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' },
|
||||
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
@@ -74,11 +75,11 @@ export const TONE_LEVELS = [
|
||||
// =============================================================================
|
||||
|
||||
export const INTENSITY_LEVELS = [
|
||||
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-sexual', color: 'bg-green-500' },
|
||||
{ level: 2, label: 'Mild', description: 'Hugging, cuddling, gentle kisses, flirty texts', color: 'bg-lime-500' },
|
||||
{ level: 3, label: 'Moderate', description: 'Making out, massage, sexy/suggestive photos', color: 'bg-yellow-500' },
|
||||
{ level: 4, label: 'Spicy', description: 'Oral, foreplay, nudes, explicit sexting', color: 'bg-orange-500' },
|
||||
{ level: 5, label: 'Intense', description: 'Sex, kinks, BDSM, explicit videos', color: 'bg-red-500' },
|
||||
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding', color: 'bg-green-500' },
|
||||
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch', color: 'bg-lime-500' },
|
||||
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery', color: 'bg-yellow-500' },
|
||||
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure', color: 'bg-orange-500' },
|
||||
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration', color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
@@ -89,41 +90,30 @@ export const INTENSITY_LEVELS = [
|
||||
export const INTENSITY_GUIDE = `
|
||||
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
|
||||
|
||||
Level 1 - LIGHT (Emotional/Non-Sexual):
|
||||
- Talking, sharing feelings, compliments, emotional vulnerability
|
||||
- Non-physical activities: writing letters, planning dates, sharing dreams
|
||||
- Clean activities with no sexual undertones
|
||||
- Examples: "Share your biggest dream", "Write a love note", "Plan a surprise date"
|
||||
Level 1 - GENTLE (Pure emotional connection & non-sexual bonding):
|
||||
- Focus: Emotional safety, friendship-based activities, quality time
|
||||
- Examples: Cook together, take a walk holding hands, give non-sexual foot massages, watch a movie cuddling
|
||||
- Emotional bonding without sexual undertones
|
||||
|
||||
Level 2 - MILD (Affectionate/Flirty):
|
||||
- Light physical affection: holding hands, hugging, cuddling
|
||||
- Gentle kisses, playing with hair, light touches
|
||||
- Flirty but non-sexual: dancing together, sleeping in each other's arms
|
||||
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Send a flirty text"
|
||||
Level 2 - WARM (Romantic atmosphere & affectionate touch):
|
||||
- Focus: Creating romantic moments, sensual but non-sexual touch, building anticipation
|
||||
- Examples: Slow dance in the living room, candlelit bath together (non-sexual), extended kissing sessions, sensual massage with oils
|
||||
- Romantic intimacy building up to arousal
|
||||
|
||||
Level 3 - MODERATE (Sensual/Suggestive):
|
||||
- Making out, passionate kissing, intimate massage
|
||||
- Sensual touch over clothes, caressing, teasing
|
||||
- Suggestive photos (lingerie, shirtless), sexy selfies, teasing texts
|
||||
- Showering together, skinny dipping
|
||||
- Examples: "Give a sensual massage", "Send a sexy photo", "Make out somewhere risky"
|
||||
Level 3 - PLAYFUL (Light sexual exploration & sensual discovery):
|
||||
- Focus: Sexual touch without penetration, playful experimentation, building arousal
|
||||
- Examples: Mutual masturbation, oral sex, light roleplay (doctor/patient), using basic toys together
|
||||
- Fun and exploratory sexual contact
|
||||
|
||||
Level 4 - SPICY (Sexual/Explicit Content):
|
||||
- Oral sex, fingering, handjobs, mutual masturbation
|
||||
- Foreplay activities, using hands/mouth on private parts
|
||||
- Nudes, explicit photos, dirty pics, sexting explicit content
|
||||
- Using toys externally, edging, making your partner cum
|
||||
- Examples: "Go down on your partner", "Send a nude", "Sext each other something filthy"
|
||||
Level 4 - STEAMY (Explicit sexual activities & moderate adventure):
|
||||
- Focus: Full sexual intercourse with adventurous elements, moderate BDSM/kink
|
||||
- Examples: Sex in different locations/positions, light bondage, anal play with toys, recording consensual intimate moments
|
||||
- Passionate and adventurous sexual connection
|
||||
|
||||
Level 5 - INTENSE (Penetration/Advanced Kinks):
|
||||
- Sex (vaginal or anal), toys inside, fingers inside
|
||||
- Kinks: bondage, role-play with power dynamics, BDSM
|
||||
- Threesomes, exhibitionism, voyeurism
|
||||
- Making/sharing explicit videos, masturbating on video call
|
||||
- Examples: "Fuck in a new location", "Try anal", "Record yourselves having sex"
|
||||
|
||||
IMPORTANT: Intensity is about SEXUAL CONTENT level, not just physical touch.
|
||||
Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos = Level 4.
|
||||
Level 5 - INTENSE (Advanced/BDSM/Extreme exploration):
|
||||
- Focus: Edge play, power dynamics, intense physical experiences
|
||||
- Examples: Heavy impact play, advanced bondage, consensual non-consent scenes, multi-partner activities
|
||||
- Very intense, taboo kinks, and extreme exploration
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
@@ -133,13 +123,11 @@ Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos
|
||||
|
||||
export const INTENSITY_GUIDE_SHORT = `
|
||||
INTENSITY LEVELS:
|
||||
1 (Light): Talking, emotional sharing, compliments, non-sexual activities
|
||||
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
|
||||
3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
|
||||
4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting, foreplay
|
||||
5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
|
||||
|
||||
IMPORTANT: "Filthy photo", "nude", "dirty pic" = Level 4. "Sexy photo", "lingerie pic" = Level 3.
|
||||
1 (Gentle): Pure emotional connection, non-sexual bonding (cooking, cuddling, foot massages)
|
||||
2 (Warm): Romantic atmosphere, affectionate touch (slow dance, sensual massage, kissing)
|
||||
3 (Playful): Light sexual exploration, sensual discovery (oral, mutual masturbation, light roleplay)
|
||||
4 (Steamy): Explicit sexual activities, moderate adventure (sex positions, light bondage, anal play)
|
||||
5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics, taboo kinks)
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
@@ -168,11 +156,11 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
- 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
|
||||
Level 1 (Gentle): Pure emotional connection, non-sexual bonding
|
||||
Level 2 (Warm): Romantic atmosphere, affectionate touch
|
||||
Level 3 (Playful): Light sexual exploration, sensual discovery (oral, toys)
|
||||
Level 4 (Steamy): Explicit sexual activities, moderate adventure (intercourse, light bondage)
|
||||
Level 5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics)
|
||||
|
||||
5. ANATOMICAL CONSISTENCY:
|
||||
- No mixed male/female anatomy in alternatives
|
||||
@@ -184,6 +172,20 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
- 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"
|
||||
|
||||
7. COUPLE TARGETING (flag if incorrect):
|
||||
- Default: ALL couples (null) unless explicit anatomical requirement
|
||||
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
|
||||
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
|
||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
||||
- Flag if targeting seems too restrictive or missing needed restrictions
|
||||
|
||||
8. INITIATOR TARGETING (for asymmetric questions only):
|
||||
- Default: null (anyone can initiate) unless explicit anatomical requirement
|
||||
- Set initiator based on WHO DOES the action in "text" field
|
||||
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
|
||||
- "Give your partner a massage" -> initiator: null (anyone can give)
|
||||
- Flag if initiator seems incorrectly assigned
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
@@ -192,24 +194,72 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
// =============================================================================
|
||||
|
||||
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
|
||||
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".',
|
||||
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
|
||||
2: 'INTIMACY LEVEL: WARM. Focus on creating romantic moments, sensual but non-sexual touch, and building anticipation. Examples: "Slow dance in the living room", "Candlelit bath together (non-sexual)", "Extended kissing sessions", "Sensual massage with oils". Romantic atmosphere & affectionate touch.',
|
||||
3: 'INTIMACY LEVEL: PLAYFUL. Focus on sexual touch without penetration, playful experimentation, and building arousal. Examples: "Mutual masturbation", "Oral sex", "Light roleplay (doctor/patient)", "Using basic toys together". Light sexual exploration & sensual discovery.',
|
||||
4: 'INTIMACY LEVEL: STEAMY. Focus on full sexual intercourse with adventurous elements and moderate BDSM/kink. Examples: "Sex in different locations/positions", "Light bondage", "Anal play with toys", "Recording consensual intimate moments". Explicit sexual activities & moderate adventure.',
|
||||
5: 'INTIMACY LEVEL: INTENSE. Focus on edge play, power dynamics, and intense physical experiences. Examples: "Heavy impact play", "Advanced bondage", "Consensual non-consent scenes", "Multi-partner activities". Advanced/BDSM/Extreme exploration.',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// CORE LANGUAGE RULES
|
||||
// Consolidated rules used across all generators
|
||||
// =============================================================================
|
||||
|
||||
export const CORE_LANGUAGE_RULES = `
|
||||
<language_rules>
|
||||
1. ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her", or gendered pronouns
|
||||
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
|
||||
5. Maintain anatomical consistency - NEVER combine male-specific and female-specific acts as alternatives
|
||||
6. Keep concise - ideal length is 5-12 words per question
|
||||
</language_rules>
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// SYSTEM MESSAGES
|
||||
// Per-tone-level system prompts for question generation
|
||||
// =============================================================================
|
||||
|
||||
export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
|
||||
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.',
|
||||
1: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
||||
|
||||
<role>Generate gentle, emotionally focused activities for bonding.</role>
|
||||
<constraint>NO sexual content whatsoever. Focus on emotional connection, quality time, and non-sexual touch.</constraint>
|
||||
<style>Warm, caring, romantic. Activities should feel safe and nurturing.</style>
|
||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||
|
||||
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
||||
|
||||
<role>Generate warm, romantic activities with affectionate touch.</role>
|
||||
<constraint>Sensual but NOT explicit sex. Building anticipation without crossing into explicit territory.</constraint>
|
||||
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
|
||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||
|
||||
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
||||
|
||||
<role>Generate playful, sexually exploratory activities.</role>
|
||||
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint>
|
||||
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style>
|
||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||
|
||||
4: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
|
||||
|
||||
<role>Generate steamy, explicit sexual activities with moderate adventure.</role>
|
||||
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint>
|
||||
<style>Passionate, explicit, adventurous. Bold but not extreme.</style>
|
||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||
|
||||
5: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
|
||||
|
||||
<role>Generate intense, advanced sexual activities including BDSM and kinks.</role>
|
||||
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint>
|
||||
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style>
|
||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
// Generate category names and category ideas
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { GeneratedCategoryIdea } from '../types';
|
||||
|
||||
/**
|
||||
@@ -11,19 +11,30 @@ import type { GeneratedCategoryIdea } from '../types';
|
||||
*/
|
||||
export async function generateCategory(): Promise<{ name: string; description: string; icon: string }> {
|
||||
const openai = getOpenAI();
|
||||
const prompt = `Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
|
||||
const prompt = `<task>
|
||||
Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
|
||||
</task>
|
||||
|
||||
<app_context>
|
||||
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
|
||||
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
|
||||
Categories organize collections of activity packs (date ideas, intimate experiences, adventures, challenges, etc.).
|
||||
This is NOT a Q&A app - it's about discovering shared interests in activities.
|
||||
</app_context>
|
||||
|
||||
Return a JSON object with:
|
||||
- name: Category name (1-3 words, e.g., "Romance", "Adventure", "Date Nights")
|
||||
- description: Brief description of what activity packs in this category contain (1 sentence)
|
||||
- icon: A single emoji that represents this category
|
||||
<examples>
|
||||
Good category names: "Romance", "Adventure", "Date Nights", "Intimacy", "Bonding", "Exploration"
|
||||
</examples>
|
||||
|
||||
Be creative and think of categories that help couples explore different types of activities together.`;
|
||||
<output_format>
|
||||
{
|
||||
"name": string, // 1-3 words (e.g., "Romance", "Adventure", "Date Nights")
|
||||
"description": string, // 1 sentence describing what activity packs contain
|
||||
"icon": string // Single emoji representing this category
|
||||
}
|
||||
|
||||
Be creative and think of categories that help couples explore different types of activities together.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
@@ -35,7 +46,7 @@ Be creative and think of categories that help couples explore different types of
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
temperature: getTemperature('generate', 0.9),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
@@ -67,32 +78,62 @@ export async function suggestCategories(
|
||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your category suggestions.`
|
||||
// Sanitize inspiration to prevent prompt injection
|
||||
const sanitizedInspiration = inspiration
|
||||
? inspiration
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
|
||||
? `\n\nIMPORTANT - DO NOT suggest any of these category names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
|
||||
const inspirationSection = sanitizedInspiration
|
||||
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
|
||||
: '';
|
||||
|
||||
const prompt = `Here are the current categories in our couples' activity/intimacy app: ${existingList}.
|
||||
const avoidSection = previousSuggestions && previousSuggestions.length > 0
|
||||
? `\n<avoid_names>\n${previousSuggestions.join(', ')}\n</avoid_names>`
|
||||
: '';
|
||||
|
||||
IMPORTANT - HOW THE APP WORKS:
|
||||
- This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals
|
||||
const prompt = `<task>
|
||||
Suggest 5 NEW, UNIQUE category ideas for organizing activity packs in a couples' activity/intimacy app.
|
||||
</task>
|
||||
|
||||
<existing_categories>
|
||||
Current categories: ${existingList}
|
||||
</existing_categories>
|
||||
|
||||
<app_context>
|
||||
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
|
||||
- Each partner independently swipes Like/Dislike/Maybe on activities
|
||||
- When BOTH partners swipe positively, they "match" and can discuss the activity
|
||||
- Categories organize collections of activity packs (date ideas, intimate experiences, adventures, etc.)
|
||||
- This is NOT a Q&A app - it's about discovering shared interests in activities to do together
|
||||
- This is NOT a Q&A app - it's about discovering shared interests in activities
|
||||
</app_context>
|
||||
${inspirationSection}
|
||||
|
||||
Suggest 5 NEW, UNIQUE category ideas that differ from the existing ones.
|
||||
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
<content_guidelines>
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}
|
||||
</content_guidelines>
|
||||
${avoidSection}
|
||||
|
||||
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
|
||||
- name: Category name (1-3 words)
|
||||
- description: Brief description of what activity packs in this category contain (1 sentence)
|
||||
- icon: A single descriptive emoji
|
||||
<category_themes>
|
||||
Focus on diverse themes: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.
|
||||
</category_themes>
|
||||
|
||||
Focus on diverse activity themes like: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.`;
|
||||
<output_format>
|
||||
{
|
||||
"ideas": [
|
||||
{
|
||||
"name": string, // 1-3 words
|
||||
"description": string, // 1 sentence, what activity packs contain
|
||||
"icon": string // Single descriptive emoji
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generate 5 unique categories that differ from existing ones.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
@@ -104,7 +145,7 @@ Focus on diverse activity themes like: date ideas, adventures, intimate experien
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
temperature: getTemperature('generate', 0.9),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -38,73 +38,91 @@ export async function evaluateQuestionPool(
|
||||
generatorIndex: q.sourceGeneratorIndex,
|
||||
}));
|
||||
|
||||
const prompt = `You are evaluating a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators.
|
||||
const prompt = `<task>
|
||||
Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators and select the best ones.
|
||||
</task>
|
||||
|
||||
Your task is to:
|
||||
<objectives>
|
||||
1. Score each question individually (1-10 on each criterion)
|
||||
2. Identify duplicates/semantically similar questions
|
||||
3. Assess uniqueness relative to the pool
|
||||
4. Consider pack theme fit
|
||||
</objectives>
|
||||
|
||||
PACK CONTEXT:
|
||||
<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})
|
||||
- Requested question count: ${requestedCount}
|
||||
</pack_context>
|
||||
|
||||
<scoring_criteria>
|
||||
${REVIEW_GUIDELINES}
|
||||
|
||||
ADDITIONAL CRITERIA FOR CHERRY-PICK:
|
||||
|
||||
5. UNIQUENESS (1-10):
|
||||
- How unique is this question compared to others in the pool?
|
||||
- 10: Completely unique concept
|
||||
- 10: Completely unique concept in the pool
|
||||
- 7-9: Somewhat similar theme but distinct execution
|
||||
- 4-6: Similar to another question but different enough to keep
|
||||
- 1-3: Near-duplicate of another question
|
||||
</scoring_criteria>
|
||||
|
||||
DUPLICATE DETECTION:
|
||||
<scoring_weights>
|
||||
Your scores will be weighted as follows for final selection:
|
||||
- Guideline Compliance: 25%
|
||||
- Creativity: 20%
|
||||
- Clarity: 20%
|
||||
- Uniqueness: 20%
|
||||
- Intensity Accuracy: 15%
|
||||
|
||||
Calibrate your scores knowing these weights. A low compliance score has the most impact.
|
||||
</scoring_weights>
|
||||
|
||||
<duplicate_detection>
|
||||
Two questions are duplicates if they:
|
||||
- Describe the same core activity/action
|
||||
- Differ only in minor phrasing (e.g., "Have sex outdoors" vs "Make love outside")
|
||||
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside")
|
||||
- Differ only in minor phrasing variations
|
||||
- Would feel repetitive if both appeared in the same pack
|
||||
|
||||
For duplicates, keep the version with:
|
||||
For duplicates, mark the WORSE version as duplicate and keep the version with:
|
||||
1. Better phrasing/creativity
|
||||
2. More appropriate intensity grading
|
||||
3. Better partner_text (if applicable)
|
||||
</duplicate_detection>
|
||||
|
||||
VERDICT RULES:
|
||||
- SELECT: High quality (avg score >= 7), no major issues - should be included
|
||||
- CONSIDER: Medium quality (avg score 5-7) - include if needed to fill count
|
||||
- SKIP: Low quality (avg score < 5) OR is a duplicate of a better question
|
||||
<verdict_rules>
|
||||
- SELECT: Average score >= 7, no major issues - should be included
|
||||
- CONSIDER: Average score 5-7 - include if needed to fill count
|
||||
- SKIP: Average score < 5 OR is a duplicate of a better question
|
||||
</verdict_rules>
|
||||
|
||||
QUESTIONS TO EVALUATE:
|
||||
<questions_to_evaluate>
|
||||
${JSON.stringify(questionsForEval, null, 2)}
|
||||
</questions_to_evaluate>
|
||||
|
||||
Return a JSON object with:
|
||||
<output_format>
|
||||
{
|
||||
"evaluations": [
|
||||
{
|
||||
"questionIndex": 0,
|
||||
"overallScore": 82,
|
||||
"questionIndex": number, // 0-based index in the questions array
|
||||
"overallScore": number, // Weighted score 0-100
|
||||
"scores": {
|
||||
"guidelineCompliance": 8,
|
||||
"creativity": 9,
|
||||
"clarity": 8,
|
||||
"intensityAccuracy": 8,
|
||||
"uniqueness": 9
|
||||
"guidelineCompliance": 1-10,
|
||||
"creativity": 1-10,
|
||||
"clarity": 1-10,
|
||||
"intensityAccuracy": 1-10,
|
||||
"uniqueness": 1-10
|
||||
},
|
||||
"isDuplicate": false,
|
||||
"duplicateOf": null,
|
||||
"issues": ["Minor issue 1"],
|
||||
"verdict": "select"
|
||||
"isDuplicate": boolean,
|
||||
"duplicateOf": number|null, // Index of the better version if duplicate
|
||||
"issues": string[], // List of issues found
|
||||
"verdict": "select"|"consider"|"skip"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Evaluate ALL ${pooledQuestions.length} questions.`;
|
||||
Evaluate ALL ${pooledQuestions.length} questions.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.reviewerModel,
|
||||
@@ -116,7 +134,7 @@ Evaluate ALL ${pooledQuestions.length} questions.`;
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
temperature: config.reviewerTemperature ?? 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
@@ -280,7 +298,16 @@ export async function performCherryPickSelection(
|
||||
index: i,
|
||||
verdict: 'pass' as const,
|
||||
issues: [],
|
||||
scores: { guidelineCompliance: 7, creativity: 7, clarity: 7, intensityAccuracy: 7 },
|
||||
scores: {
|
||||
guidelineCompliance: 7,
|
||||
creativity: 7,
|
||||
clarity: 7,
|
||||
intensityAccuracy: 7,
|
||||
anatomicalConsistency: 10,
|
||||
partnerTextQuality: 10,
|
||||
coupleTargeting: 10,
|
||||
initiatorTargeting: 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +325,11 @@ export async function performCherryPickSelection(
|
||||
creativity: evaluation.scores.creativity,
|
||||
clarity: evaluation.scores.clarity,
|
||||
intensityAccuracy: evaluation.scores.intensityAccuracy,
|
||||
// Cherry-pick doesn't evaluate these, default to passing
|
||||
anatomicalConsistency: 10,
|
||||
partnerTextQuality: 10,
|
||||
coupleTargeting: 10,
|
||||
initiatorTargeting: 10,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -143,7 +143,8 @@ export async function generateQuestionsWithCouncil(
|
||||
packDescription,
|
||||
existingQuestions,
|
||||
crudeLang,
|
||||
inspiration
|
||||
inspiration,
|
||||
gen.temperature
|
||||
);
|
||||
const result = {
|
||||
generatorIndex: index,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Generate pack names, descriptions, and pack ideas
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { GeneratedPack, GeneratedPackIdea } from '../types';
|
||||
|
||||
/**
|
||||
@@ -26,48 +26,48 @@ export async function generatePack(
|
||||
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your generation.`
|
||||
// Sanitize inspiration to prevent prompt injection
|
||||
const sanitizedInspiration = inspiration
|
||||
? inspiration
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
const avoidInstruction = previousNames && previousNames.length > 0
|
||||
? `\n\nIMPORTANT - DO NOT generate any of these pack names (already suggested): ${previousNames.join(', ')}\nGenerate something completely different and fresh.`
|
||||
const inspirationSection = sanitizedInspiration
|
||||
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
|
||||
: '';
|
||||
|
||||
const appContext = `This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
|
||||
const avoidSection = previousNames && previousNames.length > 0
|
||||
? `\n<avoid_names>\n${previousNames.join(', ')}\n</avoid_names>`
|
||||
: '';
|
||||
|
||||
const prompt = `<task>
|
||||
Generate a creative activity pack for a couples' intimacy/connection app${categoryName ? ` in the category "${categoryName}"` : ''}.
|
||||
</task>
|
||||
|
||||
<app_context>
|
||||
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
|
||||
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
|
||||
Packs contain collections of activities to try together (date ideas, intimate experiences, adventures, challenges, etc.).
|
||||
This is NOT a Q&A app - it's about discovering shared interests in activities.`;
|
||||
This is NOT a Q&A app - it's about discovering shared interests in activities.
|
||||
</app_context>
|
||||
${inspirationSection}
|
||||
|
||||
const prompt = categoryName
|
||||
? `Generate a creative activity pack for a couples' intimacy/connection app in the category "${categoryName}".
|
||||
<content_guidelines>
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}
|
||||
</content_guidelines>
|
||||
${avoidSection}
|
||||
|
||||
${appContext}
|
||||
<output_format>
|
||||
{
|
||||
"name": string, // Catchy pack name, 3-6 words, evokes activities/experiences
|
||||
"description": string // Brief, enticing description, 1-2 sentences, activity-focused
|
||||
}
|
||||
|
||||
The pack should contain a themed collection of activities couples can explore together.
|
||||
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
|
||||
Return a JSON object with:
|
||||
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
|
||||
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
|
||||
|
||||
Make it romantic, playful, and activity-focused.`
|
||||
: `Generate a creative activity pack for a couples' intimacy/connection app.
|
||||
|
||||
${appContext}
|
||||
|
||||
The pack should contain a themed collection of activities couples can explore together.
|
||||
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
|
||||
Return a JSON object with:
|
||||
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
|
||||
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
|
||||
|
||||
Make it romantic, playful, and activity-focused.`;
|
||||
Make it romantic, playful, and activity-focused.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
@@ -79,7 +79,7 @@ This is NOT a Q&A app - it's about discovering shared interests in activities.`;
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.8,
|
||||
temperature: getTemperature('generate', 0.8),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
@@ -112,42 +112,67 @@ export async function suggestPacks(
|
||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your pack suggestions.`
|
||||
// Sanitize inspiration to prevent prompt injection
|
||||
const sanitizedInspiration = inspiration
|
||||
? inspiration
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '')
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
|
||||
? `\n\nIMPORTANT - DO NOT suggest any of these pack names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
|
||||
const inspirationSection = sanitizedInspiration
|
||||
? `\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>`
|
||||
: '';
|
||||
|
||||
const prompt = `We are building activity packs for the category "${categoryName}" in a couples' intimacy app.
|
||||
const avoidSection = previousSuggestions && previousSuggestions.length > 0
|
||||
? `\n<avoid_names>\n${previousSuggestions.join(', ')}\n</avoid_names>`
|
||||
: '';
|
||||
|
||||
IMPORTANT - HOW THE APP WORKS:
|
||||
- This is an ACTIVITY-FOCUSED app, NOT a Q&A app
|
||||
const prompt = `<task>
|
||||
Suggest 5 NEW, UNIQUE activity pack ideas for the category "${categoryName}" in a couples' intimacy app.
|
||||
</task>
|
||||
|
||||
<app_context>
|
||||
This is an ACTIVITY-FOCUSED app, NOT a Q&A app.
|
||||
- Packs contain ACTIVITY PROPOSALS (things couples can do together)
|
||||
- Each partner independently swipes Like/Dislike/Maybe on activities
|
||||
- When BOTH partners swipe positively on the same activity, they "match" and can discuss it
|
||||
- Activities are things like: date ideas, intimate experiences, adventures, challenges, conversations to have, etc.
|
||||
- This is NOT about asking each other questions - it's about discovering shared interests in activities
|
||||
- When BOTH partners swipe positively, they "match" and can discuss it
|
||||
- Activities include: date ideas, intimate experiences, adventures, challenges, conversations
|
||||
- Focus on discovering shared interests in activities, not asking questions
|
||||
</app_context>
|
||||
${inspirationSection}
|
||||
|
||||
PACK THEMES should focus on:
|
||||
<pack_themes>
|
||||
Focus on:
|
||||
- Collections of related activities couples might want to try
|
||||
- Experiences to share together
|
||||
- Things to do, not questions to ask
|
||||
- Date ideas, adventures, intimate moments, challenges, bonding activities
|
||||
</pack_themes>
|
||||
|
||||
Existing packs in this category: ${existingList}.
|
||||
<existing_packs>
|
||||
Current packs in this category: ${existingList}
|
||||
</existing_packs>
|
||||
|
||||
Suggest 5 NEW, UNIQUE pack ideas that fit this category and differ from existing ones.
|
||||
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
<content_guidelines>
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}
|
||||
</content_guidelines>
|
||||
${avoidSection}
|
||||
|
||||
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
|
||||
- name: Pack name (catchy, 3-6 words) - should evoke activities/experiences, not questions
|
||||
- description: Brief description (1-2 sentences) focusing on the activities/experiences in the pack
|
||||
- icon: An Ionicon name from this list: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
|
||||
<output_format>
|
||||
{
|
||||
"ideas": [
|
||||
{
|
||||
"name": string, // Catchy, 3-6 words, evokes activities/experiences
|
||||
"description": string, // 1-2 sentences, activity-focused
|
||||
"icon": string // Ionicon: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Make them engaging and specific to "${categoryName}".`;
|
||||
Generate 5 unique pack ideas that differ from existing ones. Make them engaging and specific to "${categoryName}".
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
@@ -159,7 +184,7 @@ Make them engaging and specific to "${categoryName}".`;
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
temperature: getTemperature('generate', 0.9),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// Generate questions for packs with various tone/intensity settings
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES } from '../config';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES, CORE_LANGUAGE_RULES } from '../config';
|
||||
import type { GeneratedQuestion, ToneLevel } from '../types';
|
||||
|
||||
/**
|
||||
@@ -20,53 +20,59 @@ function buildQuestionPrompt(
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string
|
||||
): string {
|
||||
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.`
|
||||
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
|
||||
const sanitizedInspiration = inspiration
|
||||
? inspiration
|
||||
.replace(/<[^>]*>/g, '') // Remove XML-like tags
|
||||
.replace(/\b(CRITICAL|PRIORITY|INSTRUCTION|OVERRIDE|IGNORE|SYSTEM|ASSISTANT)\b:?/gi, '') // Remove authority words
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
const intensityInstruction = isClean
|
||||
? 'All questions should be intensity level 1 (non-physical activities).'
|
||||
const inspirationInstruction = sanitizedInspiration
|
||||
? `\n\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>\n\nIncorporate the user guidance above into your generation where appropriate. Use it to inform the theme and style of questions.`
|
||||
: '';
|
||||
|
||||
const intensityInstruction = tone === 1
|
||||
? 'All questions should be intensity level 1 (non-sexual bonding).'
|
||||
: intensity
|
||||
? `All questions should be at intensity level ${intensity}.`
|
||||
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
|
||||
: 'Vary the intensity levels appropriate for the tone.';
|
||||
|
||||
const toneInstruction = TONE_INSTRUCTIONS[tone];
|
||||
|
||||
// Examples based on tone level
|
||||
const symmetricExamples = isClean
|
||||
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
|
||||
const symmetricExamples = tone <= 2
|
||||
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".'
|
||||
: 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".';
|
||||
? 'GOOD: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
|
||||
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
|
||||
|
||||
const asymmetricExamples = isClean
|
||||
const asymmetricExamples = tone <= 2
|
||||
? `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"`
|
||||
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
|
||||
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by 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"`
|
||||
- text: "Spank your partner" → partner_text: "Get spanked by your partner"
|
||||
- text: "Use a toy on your partner" → partner_text: "Have a toy used on you"`
|
||||
: `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"`;
|
||||
- text: "Perform oral on your partner" → partner_text: "Receive oral from your partner"
|
||||
- text: "Tease your partner" → partner_text: "Be teased by your partner"
|
||||
- text: "Undress your partner" → partner_text: "Be undressed 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.'
|
||||
const explicitWarning = tone === 1
|
||||
? '\n\nCRITICAL: This is a GENTLE pack. Focus on emotional connection and non-sexual bonding. NO explicit sexual acts.'
|
||||
: 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.';
|
||||
: '\n\nCRITICAL: Avoid extreme kinks or hardcore content unless specifically requested.';
|
||||
|
||||
const descriptionContext = packDescription
|
||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||
@@ -79,43 +85,55 @@ ${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.`
|
||||
: '';
|
||||
|
||||
return `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}${existingQuestionsContext}
|
||||
return `<task>
|
||||
Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
|
||||
</task>
|
||||
${descriptionContext}${inspirationInstruction}${existingQuestionsContext}
|
||||
|
||||
<intensity_guide>
|
||||
${INTENSITY_GUIDE_SHORT}
|
||||
</intensity_guide>
|
||||
|
||||
<instructions>
|
||||
${intensityInstruction}
|
||||
${toneInstruction}${explicitWarning}${crudeLangInstruction}${inspirationInstruction}
|
||||
${toneInstruction}${explicitWarning}${crudeLangInstruction}
|
||||
</instructions>
|
||||
|
||||
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" for specific actions, NOT interview questions.
|
||||
${CORE_LANGUAGE_RULES}
|
||||
|
||||
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):
|
||||
<examples>
|
||||
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..."
|
||||
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
|
||||
The card reader is the DOER. Their partner is "your partner".
|
||||
</examples>
|
||||
|
||||
<question_types>
|
||||
1. SYMMETRIC Activities (partner_text = null):
|
||||
- Both partners do the same thing together.
|
||||
- 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.
|
||||
- One partner does something TO/FOR the other, or roles differ
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER does/experiences
|
||||
- ${asymmetricExamples}
|
||||
</question_types>
|
||||
|
||||
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.`;
|
||||
<output_format>
|
||||
Return a JSON object with this exact structure:
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
||||
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
|
||||
"intensity": 1-5, // REQUIRED: must match the intensity level guidelines
|
||||
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
|
||||
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
||||
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
|
||||
}
|
||||
]
|
||||
}
|
||||
</output_format>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +172,7 @@ export async function generateQuestions(
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
temperature: getTemperature('generate', 0.9),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
@@ -177,7 +195,8 @@ export async function generateQuestionsWithModel(
|
||||
packDescription: string | undefined,
|
||||
existingQuestions: string[] | undefined,
|
||||
crudeLang: boolean,
|
||||
inspiration: string | undefined
|
||||
inspiration: string | undefined,
|
||||
temperature?: number
|
||||
): Promise<GeneratedQuestion[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
@@ -202,7 +221,7 @@ export async function generateQuestionsWithModel(
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
temperature: temperature ?? getTemperature('generate', 0.9),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -25,6 +25,8 @@ export type {
|
||||
CherryPickResult,
|
||||
TargetAnalysis,
|
||||
TextAnalysis,
|
||||
DeletionAnalysis,
|
||||
PropsAnalysis,
|
||||
ExtractedTopic,
|
||||
TopicExtractionResult,
|
||||
SelectionResult,
|
||||
@@ -59,6 +61,8 @@ export { reviewGeneratedQuestions, selectBestGeneration } from './review';
|
||||
// Analyzers
|
||||
export { analyzeQuestionText } from './analyzers/text';
|
||||
export { analyzeQuestionTargets } from './analyzers/targets';
|
||||
export { analyzeQuestionDeletions } from './analyzers/deletions';
|
||||
export { analyzeQuestionProps } from './analyzers/props';
|
||||
|
||||
// Tools
|
||||
export { polishContent } from './tools/polish';
|
||||
|
||||
+121
-55
@@ -39,47 +39,68 @@ export async function reviewGeneratedQuestions(
|
||||
intensity: q.intensity,
|
||||
location_type: q.location_type,
|
||||
effort_level: q.effort_level,
|
||||
// Include targeting for review (may not always be present)
|
||||
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
|
||||
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
|
||||
}));
|
||||
|
||||
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.
|
||||
const prompt = `<task>
|
||||
Review each generated question against our quality guidelines.
|
||||
</task>
|
||||
|
||||
PACK CONTEXT:
|
||||
<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})
|
||||
</pack_context>
|
||||
|
||||
<scoring_criteria>
|
||||
${REVIEW_GUIDELINES}
|
||||
</scoring_criteria>
|
||||
|
||||
VERDICT RULES:
|
||||
<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)
|
||||
</verdict_rules>
|
||||
|
||||
QUESTIONS TO REVIEW:
|
||||
<questions_to_review>
|
||||
${JSON.stringify(questionsForReview, null, 2)}
|
||||
</questions_to_review>
|
||||
|
||||
Return a JSON object with:
|
||||
<output_format>
|
||||
{
|
||||
"reviews": [
|
||||
{
|
||||
"index": 0,
|
||||
"verdict": "pass" | "flag" | "reject",
|
||||
"issues": ["Issue 1", "Issue 2"],
|
||||
"suggestions": "Optional improvement suggestion",
|
||||
"index": number, // 0-based index of the question
|
||||
"verdict": "pass"|"flag"|"reject",
|
||||
"issues": string[], // List of issues found, empty if none
|
||||
"suggestions": string|null, // Optional improvement suggestion
|
||||
"scores": {
|
||||
"guidelineCompliance": 8,
|
||||
"creativity": 7,
|
||||
"clarity": 9,
|
||||
"intensityAccuracy": 8
|
||||
}
|
||||
"guidelineCompliance": 1-10, // Criterion 1
|
||||
"creativity": 1-10, // Criterion 2
|
||||
"clarity": 1-10, // Criterion 3
|
||||
"intensityAccuracy": 1-10, // Criterion 4
|
||||
"anatomicalConsistency": 1-10, // Criterion 5
|
||||
"partnerTextQuality": 1-10, // Criterion 6 (10 if no partner_text needed)
|
||||
"coupleTargeting": 1-10, // Criterion 7
|
||||
"initiatorTargeting": 1-10 // Criterion 8 (10 if symmetric)
|
||||
},
|
||||
"intensitySuggestion": number|null, // Suggested intensity (1-5) if current seems wrong, null if correct
|
||||
"targetingSuggestions": { // Only include if targeting needs changes
|
||||
"suggestedCoupleTargets": string[]|null, // ["male+male", "female+male", "female+female"] or subset
|
||||
"suggestedInitiator": string[]|null, // ["male", "female"] or null for any
|
||||
"reason": string // Why targeting should change
|
||||
}|null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Review ALL questions. Be thorough but fair - only flag/reject for genuine issues.`;
|
||||
Review ALL questions against ALL 8 criteria. Be thorough but fair - only flag/reject for genuine issues.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.reviewerModel,
|
||||
@@ -91,7 +112,7 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
temperature: config.reviewerTemperature ?? 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
@@ -111,19 +132,31 @@ Review ALL questions. Be thorough but fair - only flag/reject for genuine issues
|
||||
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;
|
||||
// Calculate overall quality as weighted average of all 8 scores
|
||||
// Weights: guidelineCompliance 20%, creativity 15%, clarity 15%, intensityAccuracy 15%,
|
||||
// anatomicalConsistency 15%, partnerTextQuality 10%, coupleTargeting 5%, initiatorTargeting 5%
|
||||
let totalWeightedScore = 0;
|
||||
let totalWeight = 0;
|
||||
const weights = {
|
||||
guidelineCompliance: 0.20,
|
||||
creativity: 0.15,
|
||||
clarity: 0.15,
|
||||
intensityAccuracy: 0.15,
|
||||
anatomicalConsistency: 0.15,
|
||||
partnerTextQuality: 0.10,
|
||||
coupleTargeting: 0.05,
|
||||
initiatorTargeting: 0.05,
|
||||
};
|
||||
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;
|
||||
for (const [key, weight] of Object.entries(weights)) {
|
||||
const score = review.scores[key as keyof typeof review.scores] || 0;
|
||||
totalWeightedScore += score * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
||||
const overallQuality = totalWeight > 0 ? Math.round((totalWeightedScore / (totalWeight / Object.keys(weights).length)) * 10) / 10 : 0;
|
||||
|
||||
return {
|
||||
reviews,
|
||||
@@ -168,54 +201,77 @@ export async function selectBestGeneration(
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
|
||||
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
|
||||
})),
|
||||
}));
|
||||
|
||||
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:
|
||||
const prompt = `<task>
|
||||
Compare ${candidates.length} different sets of generated questions from different AI models. Select the best set and review it in detail.
|
||||
</task>
|
||||
|
||||
<objectives>
|
||||
1. COMPARE all sets and SELECT the BEST one overall
|
||||
2. REVIEW the selected set in detail
|
||||
</objectives>
|
||||
|
||||
PACK CONTEXT:
|
||||
<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})
|
||||
</pack_context>
|
||||
|
||||
<scoring_criteria>
|
||||
${REVIEW_GUIDELINES}
|
||||
</scoring_criteria>
|
||||
|
||||
CANDIDATE SETS:
|
||||
<candidate_sets>
|
||||
${JSON.stringify(candidatesForReview, null, 2)}
|
||||
</candidate_sets>
|
||||
|
||||
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
|
||||
<selection_criteria>
|
||||
Ranked by importance (approximate weights):
|
||||
1. Overall quality and creativity of questions (30%)
|
||||
2. Variety and uniqueness within the set (20%)
|
||||
3. Adherence to pack theme and tone (20%)
|
||||
4. Proper intensity grading (15%)
|
||||
5. Correct use of "your partner" language (10%)
|
||||
6. Quality of partner_text for asymmetric questions (5%)
|
||||
</selection_criteria>
|
||||
|
||||
Return a JSON object with:
|
||||
<output_format>
|
||||
{
|
||||
"selectedIndex": <0-based index of the best candidate set>,
|
||||
"reasoning": "<brief explanation of why this set was chosen>",
|
||||
"selectedIndex": number, // 0-based index of the best candidate set
|
||||
"reasoning": string, // Brief explanation of why this set was chosen
|
||||
"reviews": [
|
||||
{
|
||||
"index": <question index within selected set>,
|
||||
"verdict": "pass" | "flag" | "reject",
|
||||
"issues": ["Issue 1", "Issue 2"],
|
||||
"suggestions": "Optional improvement suggestion",
|
||||
"index": number, // Question index within selected set
|
||||
"verdict": "pass"|"flag"|"reject",
|
||||
"issues": string[],
|
||||
"suggestions": string|null,
|
||||
"scores": {
|
||||
"guidelineCompliance": 1-10,
|
||||
"creativity": 1-10,
|
||||
"clarity": 1-10,
|
||||
"intensityAccuracy": 1-10
|
||||
}
|
||||
"intensityAccuracy": 1-10,
|
||||
"anatomicalConsistency": 1-10,
|
||||
"partnerTextQuality": 1-10,
|
||||
"coupleTargeting": 1-10,
|
||||
"initiatorTargeting": 1-10
|
||||
},
|
||||
"intensitySuggestion": number|null,
|
||||
"targetingSuggestions": {
|
||||
"suggestedCoupleTargets": string[]|null,
|
||||
"suggestedInitiator": string[]|null,
|
||||
"reason": string
|
||||
}|null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Select the best set and review ALL questions in that set.`;
|
||||
Select the best set and review ALL questions in that set against ALL 8 criteria.
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.reviewerModel,
|
||||
@@ -227,7 +283,7 @@ Select the best set and review ALL questions in that set.`;
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
temperature: config.reviewerTemperature ?? 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
@@ -244,23 +300,33 @@ Select the best set and review ALL questions in that set.`;
|
||||
const reviews: QuestionReview[] = parsed.reviews || [];
|
||||
const reasoning = parsed.reasoning || 'No reasoning provided';
|
||||
|
||||
// Calculate summary
|
||||
// Calculate summary with weighted average of all 8 scores
|
||||
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;
|
||||
let totalWeightedScore = 0;
|
||||
let totalWeight = 0;
|
||||
const weights = {
|
||||
guidelineCompliance: 0.20,
|
||||
creativity: 0.15,
|
||||
clarity: 0.15,
|
||||
intensityAccuracy: 0.15,
|
||||
anatomicalConsistency: 0.15,
|
||||
partnerTextQuality: 0.10,
|
||||
coupleTargeting: 0.05,
|
||||
initiatorTargeting: 0.05,
|
||||
};
|
||||
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;
|
||||
for (const [key, weight] of Object.entries(weights)) {
|
||||
const score = review.scores[key as keyof typeof review.scores] || 0;
|
||||
totalWeightedScore += score * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
||||
const overallQuality = totalWeight > 0 ? Math.round((totalWeightedScore / (totalWeight / Object.keys(weights).length)) * 10) / 10 : 0;
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Polish and improve content text
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
|
||||
type PolishType = 'question' | 'partner_text' | 'pack_name' | 'pack_description' | 'category_name';
|
||||
|
||||
@@ -64,20 +64,32 @@ export async function polishContent(
|
||||
`;
|
||||
}
|
||||
|
||||
const prompt = `Please polish, improve, and tidy up the following text, which is used as ${contextMap[type] || 'text in the app'}.
|
||||
const prompt = `<task>
|
||||
Polish, improve, and tidy up the following text used as ${contextMap[type] || 'text in the app'}.
|
||||
</task>
|
||||
|
||||
Original text: "${text}"
|
||||
<original_text>
|
||||
"${text}"
|
||||
</original_text>
|
||||
|
||||
${explicitInstruction}
|
||||
<content_type>
|
||||
${explicitInstruction}
|
||||
</content_type>
|
||||
|
||||
${additionalRules}
|
||||
${fewShotExamples}
|
||||
<rules>
|
||||
${additionalRules}
|
||||
- Make it concise, engaging, and grammatically correct
|
||||
- Maintain the original intent and meaning
|
||||
- Do NOT change the core action - only improve phrasing
|
||||
</rules>
|
||||
|
||||
Make it concise, engaging, and grammatically correct.
|
||||
Maintain the original intent and meaning.
|
||||
${fewShotExamples}
|
||||
|
||||
Return a JSON object with:
|
||||
- polished: The improved text string`;
|
||||
<output_format>
|
||||
{
|
||||
"polished": string // The improved text
|
||||
}
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('polish'),
|
||||
@@ -89,7 +101,7 @@ export async function polishContent(
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
temperature: getTemperature('polish', 0.7),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Extract topics from packs for categorization
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||
import type { TopicExtractionResult } from '../types';
|
||||
|
||||
/**
|
||||
@@ -20,34 +20,47 @@ export async function extractTopicsFromPack(
|
||||
const existingTopicNames = existingTopics.map(t => t.name);
|
||||
const existingTopicsJson = JSON.stringify(existingTopics);
|
||||
|
||||
const prompt = `Analyze the following question pack and extract relevant topics/kinks/interests that describe its content.
|
||||
const prompt = `<task>
|
||||
Analyze the question pack and extract relevant topics/kinks/interests that describe its content.
|
||||
</task>
|
||||
|
||||
PACK NAME: "${packName}"
|
||||
${packDescription ? `PACK DESCRIPTION: "${packDescription}"` : ''}
|
||||
<pack_info>
|
||||
Pack Name: "${packName}"
|
||||
${packDescription ? `Pack Description: "${packDescription}"` : ''}
|
||||
</pack_info>
|
||||
|
||||
QUESTIONS IN THIS PACK:
|
||||
<questions>
|
||||
${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
|
||||
</questions>
|
||||
|
||||
EXISTING TOPICS IN THE SYSTEM (prefer matching these when appropriate):
|
||||
${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
|
||||
<existing_topics>
|
||||
Available topics (prefer matching these): ${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
|
||||
|
||||
EXISTING TOPICS WITH IDs:
|
||||
${existingTopicsJson}
|
||||
Topic IDs: ${existingTopicsJson}
|
||||
</existing_topics>
|
||||
|
||||
INSTRUCTIONS:
|
||||
<instructions>
|
||||
1. Identify 1-5 topics that best describe this pack's content
|
||||
2. Topics should be specific kinks, interests, or themes (e.g., "Bondage", "Exhibitionism", "Voyeurism", "Role Play", "Sensory Play", "Oral", "Anal", "Communication", "Romance", "Adventure")
|
||||
3. ALWAYS prefer matching an existing topic if one fits - use the EXACT name and include the existingTopicId
|
||||
4. Only suggest a new topic if nothing in the existing list is appropriate
|
||||
5. Topic names should be title case (e.g., "Bondage" not "bondage")
|
||||
6. Keep topics broad enough to be reusable across packs
|
||||
2. Topics should be specific kinks, interests, or themes
|
||||
- Examples: Bondage, Exhibitionism, Voyeurism, Role Play, Sensory Play, Oral, Anal, Communication, Romance, Adventure
|
||||
3. ALWAYS prefer matching an existing topic - use the EXACT name and include existingTopicId
|
||||
4. Only suggest a new topic if nothing in existing list is appropriate
|
||||
5. Topic names: Title Case (e.g., "Bondage" not "bondage")
|
||||
6. Keep topics broad enough to be reusable across multiple packs
|
||||
</instructions>
|
||||
|
||||
Return a JSON object with:
|
||||
- topics: Array of objects with:
|
||||
- name: The topic name (use exact existing name if matching)
|
||||
- isNew: false if matching an existing topic, true if suggesting a new one
|
||||
- existingTopicId: The ID of the existing topic if isNew is false (from the EXISTING TOPICS WITH IDs list)
|
||||
- reasoning: Brief explanation of why these topics were chosen`;
|
||||
<output_format>
|
||||
{
|
||||
"topics": [
|
||||
{
|
||||
"name": string, // Topic name (use exact existing name if matching)
|
||||
"isNew": boolean, // false if matching existing, true if new
|
||||
"existingTopicId": string|null // ID from existing topics if isNew is false
|
||||
}
|
||||
],
|
||||
"reasoning": string // Brief explanation of why these topics were chosen
|
||||
}
|
||||
</output_format>`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
@@ -59,7 +72,7 @@ Return a JSON object with:
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
temperature: getTemperature('fix', 0.3),
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ModelPurpose = 'generate' | 'fix' | 'polish' | 'describe_image';
|
||||
|
||||
export interface CouncilGenerator {
|
||||
model: string;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
|
||||
@@ -20,6 +21,7 @@ export interface CouncilConfig {
|
||||
enabled: boolean;
|
||||
generators: CouncilGenerator[];
|
||||
reviewerModel: string;
|
||||
reviewerTemperature?: number;
|
||||
selectionMode: CouncilSelectionMode;
|
||||
cherryPickEnsureIntensityDistribution: boolean;
|
||||
}
|
||||
@@ -62,16 +64,29 @@ export interface GeneratedPackIdea {
|
||||
// REVIEW TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface QuestionReviewScores {
|
||||
guidelineCompliance: number;
|
||||
creativity: number;
|
||||
clarity: number;
|
||||
intensityAccuracy: number;
|
||||
anatomicalConsistency: number;
|
||||
partnerTextQuality: number;
|
||||
coupleTargeting: number;
|
||||
initiatorTargeting: number;
|
||||
}
|
||||
|
||||
export interface QuestionReview {
|
||||
index: number;
|
||||
verdict: 'pass' | 'flag' | 'reject';
|
||||
issues: string[];
|
||||
suggestions?: string;
|
||||
scores: {
|
||||
guidelineCompliance: number;
|
||||
creativity: number;
|
||||
clarity: number;
|
||||
intensityAccuracy: number;
|
||||
scores: QuestionReviewScores;
|
||||
// Additional detailed feedback
|
||||
intensitySuggestion?: number | null; // Suggested intensity if current seems wrong
|
||||
targetingSuggestions?: {
|
||||
suggestedCoupleTargets?: string[] | null;
|
||||
suggestedInitiator?: string[] | null;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,6 +203,22 @@ export interface TextAnalysis {
|
||||
suggested_text: string;
|
||||
suggested_partner_text: string | null;
|
||||
reason: string;
|
||||
// Intensity suggestion (only if AI thinks current is wrong)
|
||||
suggested_intensity?: number | null;
|
||||
intensity_reason?: string | null;
|
||||
}
|
||||
|
||||
export interface DeletionAnalysis {
|
||||
id: string;
|
||||
category: 'duplicate' | 'redundant' | 'off-tone' | 'unsafe' | 'too-vague' | 'broken' | 'off-topic';
|
||||
reason: string;
|
||||
duplicate_of_id?: string | null;
|
||||
}
|
||||
|
||||
export interface PropsAnalysis {
|
||||
id: string;
|
||||
suggested_required_props: string[] | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ExtractedTopic {
|
||||
@@ -216,4 +247,4 @@ export interface SelectionResult {
|
||||
// TONE LEVEL TYPE
|
||||
// =============================================================================
|
||||
|
||||
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
export type ToneLevel = 1 | 2 | 3 | 4 | 5;
|
||||
@@ -66,17 +66,23 @@ export function AiSettingsPage() {
|
||||
setFormData({
|
||||
openrouter_api_key: config.openrouter_api_key || '',
|
||||
default_model: config.default_model || '',
|
||||
default_temperature: config.default_temperature,
|
||||
model_generate: config.model_generate || '',
|
||||
temperature_generate: config.temperature_generate,
|
||||
model_fix: config.model_fix || '',
|
||||
temperature_fix: config.temperature_fix,
|
||||
model_polish: config.model_polish || '',
|
||||
temperature_polish: config.temperature_polish,
|
||||
council_enabled: config.council_enabled || false,
|
||||
council_generator_model: config.council_generator_model || '',
|
||||
council_generators: generators,
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_reviewer_temperature: config.council_reviewer_temperature,
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_temperature: config.classifier_temperature,
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
heuristics_enabled: config.heuristics_enabled ?? false,
|
||||
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
|
||||
@@ -164,17 +170,23 @@ export function AiSettingsPage() {
|
||||
setFormData({
|
||||
openrouter_api_key: config.openrouter_api_key || '',
|
||||
default_model: config.default_model || '',
|
||||
default_temperature: config.default_temperature,
|
||||
model_generate: config.model_generate || '',
|
||||
temperature_generate: config.temperature_generate,
|
||||
model_fix: config.model_fix || '',
|
||||
temperature_fix: config.temperature_fix,
|
||||
model_polish: config.model_polish || '',
|
||||
temperature_polish: config.temperature_polish,
|
||||
council_enabled: config.council_enabled || false,
|
||||
council_generator_model: config.council_generator_model || '',
|
||||
council_generators: generators,
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_reviewer_temperature: config.council_reviewer_temperature,
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_temperature: config.classifier_temperature,
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
heuristics_enabled: config.heuristics_enabled ?? false,
|
||||
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
|
||||
@@ -208,8 +220,8 @@ export function AiSettingsPage() {
|
||||
handleChange('council_generators', newGenerators);
|
||||
};
|
||||
|
||||
const updateGeneratorModel = (index: number, model: string) => {
|
||||
const newGenerators = generators.map((g, i) => i === index ? { ...g, model } : g);
|
||||
const updateGenerator = (index: number, updates: Partial<CouncilGenerator>) => {
|
||||
const newGenerators = generators.map((g, i) => i === index ? { ...g, ...updates } : g);
|
||||
handleChange('council_generators', newGenerators);
|
||||
};
|
||||
|
||||
@@ -367,16 +379,31 @@ export function AiSettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-model">Classifier Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.classifier_model || ''}
|
||||
onChange={(value) => handleChange('classifier_model', value)}
|
||||
placeholder="openai/gpt-4o"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used for classification (must support vision if image moderation is needed)
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,120px] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-model">Classifier Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.classifier_model || ''}
|
||||
onChange={(value) => handleChange('classifier_model', value)}
|
||||
placeholder="openai/gpt-4o"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used for classification (must support vision if image moderation is needed)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-temperature">Temp</Label>
|
||||
<Input
|
||||
id="classifier-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.classifier_temperature ?? ''}
|
||||
onChange={(e) => handleChange('classifier_temperature', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -624,16 +651,31 @@ export function AiSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-model">Default Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.default_model || ''}
|
||||
onChange={(value) => handleChange('default_model', value)}
|
||||
placeholder="openai/gpt-4o-mini"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fallback model when no specific model is configured
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,120px] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-model">Default Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.default_model || ''}
|
||||
onChange={(value) => handleChange('default_model', value)}
|
||||
placeholder="openai/gpt-4o-mini"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fallback model when no specific model is configured
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-temperature">Temp</Label>
|
||||
<Input
|
||||
id="default-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.default_temperature ?? ''}
|
||||
onChange={(e) => handleChange('default_temperature', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="0.7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -663,16 +705,31 @@ export function AiSettingsPage() {
|
||||
|
||||
{!formData.council_enabled ? (
|
||||
/* Single generator mode */
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Generation Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(value) => handleChange('model_generate', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For questions, category ideas, and pack ideas
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,120px] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Generation Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(value) => handleChange('model_generate', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For questions, category ideas, and pack ideas
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generate-temperature">Temp</Label>
|
||||
<Input
|
||||
id="generate-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.temperature_generate ?? ''}
|
||||
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="0.9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Council mode - multiple generators + reviewer */
|
||||
@@ -698,10 +755,21 @@ export function AiSettingsPage() {
|
||||
</div>
|
||||
<ModelCombobox
|
||||
value={gen.model}
|
||||
onChange={(value) => updateGeneratorModel(index, value)}
|
||||
onChange={(value) => updateGenerator(index, { model: value })}
|
||||
placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Select model..."}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={gen.temperature ?? ''}
|
||||
onChange={(e) => updateGenerator(index, { temperature: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||||
placeholder="0.9"
|
||||
className="w-[80px]"
|
||||
aria-label={`Generator ${index + 1} temperature`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -717,16 +785,31 @@ export function AiSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Reviewer */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.council_reviewer_model || ''}
|
||||
onChange={(value) => handleChange('council_reviewer_model', value)}
|
||||
placeholder="google/gemini-pro-1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reviews outputs and selects the best
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,120px] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.council_reviewer_model || ''}
|
||||
onChange={(value) => handleChange('council_reviewer_model', value)}
|
||||
placeholder="google/gemini-pro-1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reviews outputs and selects the best
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reviewer-temperature" className="text-sm">Temp</Label>
|
||||
<Input
|
||||
id="reviewer-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.council_reviewer_temperature ?? ''}
|
||||
onChange={(e) => handleChange('council_reviewer_temperature', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="0.3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Mode */}
|
||||
@@ -794,16 +877,31 @@ export function AiSettingsPage() {
|
||||
|
||||
{/* Ideas Model - shown when council mode is ON (since council only applies to questions) */}
|
||||
{formData.council_enabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Ideas Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(value) => handleChange('model_generate', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For category ideas and pack ideas (council mode only applies to questions)
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,120px] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Ideas Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(value) => handleChange('model_generate', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For category ideas and pack ideas (council mode only applies to questions)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ideas-temperature">Temp</Label>
|
||||
<Input
|
||||
id="ideas-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.temperature_generate ?? ''}
|
||||
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="0.9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -812,28 +910,58 @@ export function AiSettingsPage() {
|
||||
|
||||
{/* Other models - always visible */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-fix">Analysis Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_fix || ''}
|
||||
onChange={(value) => handleChange('model_fix', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For analyzing & fixing content
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,100px] gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-fix">Analysis Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_fix || ''}
|
||||
onChange={(value) => handleChange('model_fix', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For analyzing & fixing content
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fix-temperature">Temp</Label>
|
||||
<Input
|
||||
id="fix-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.temperature_fix ?? ''}
|
||||
onChange={(e) => handleChange('temperature_fix', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-polish">Polish Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_polish || ''}
|
||||
onChange={(value) => handleChange('model_polish', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For polishing & improving text
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr,100px] gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-polish">Polish Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_polish || ''}
|
||||
onChange={(value) => handleChange('model_polish', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For polishing & improving text
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="polish-temperature">Temp</Label>
|
||||
<Input
|
||||
id="polish-temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.temperature_polish ?? ''}
|
||||
onChange={(e) => handleChange('temperature_polish', e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder="0.7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
@@ -36,6 +38,7 @@ interface Category {
|
||||
icon: string | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
pack_count?: number;
|
||||
}
|
||||
|
||||
@@ -43,6 +46,7 @@ interface CategoryFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -60,11 +64,12 @@ export function CategoriesPage() {
|
||||
|
||||
// Form state using the new hook
|
||||
const form = useEntityForm<CategoryFormData, Category>(
|
||||
{ name: '', description: '', icon: 'bookmark-outline' },
|
||||
{ name: '', description: '', icon: 'bookmark-outline', is_public: true },
|
||||
(category) => ({
|
||||
name: category.name,
|
||||
description: category.description || '',
|
||||
icon: category.icon || 'bookmark-outline',
|
||||
is_public: category.is_public ?? true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -139,6 +144,7 @@ export function CategoriesPage() {
|
||||
name: idea.name,
|
||||
description: idea.description || '',
|
||||
icon: idea.icon || 'bookmark-outline',
|
||||
is_public: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -154,6 +160,7 @@ export function CategoriesPage() {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
is_public: form.formData.is_public,
|
||||
};
|
||||
|
||||
if (form.editingItem) {
|
||||
@@ -207,14 +214,19 @@ export function CategoriesPage() {
|
||||
const currentOrder = category.sort_order ?? currentIndex;
|
||||
const targetOrder = targetCategory.sort_order ?? targetIndex;
|
||||
|
||||
await Promise.all([
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update category order');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const newCategories = [...categories];
|
||||
[newCategories[currentIndex], newCategories[targetIndex]] = [newCategories[targetIndex], newCategories[currentIndex]];
|
||||
newCategories[currentIndex] = { ...targetCategory, sort_order: currentOrder };
|
||||
newCategories[targetIndex] = { ...category, sort_order: targetOrder };
|
||||
setCategories(newCategories);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder categories');
|
||||
@@ -223,6 +235,27 @@ export function CategoriesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (category: Category) => {
|
||||
try {
|
||||
// Optimistic update
|
||||
setCategories(categories.map(c =>
|
||||
c.id === category.id ? { ...c, is_public: !c.is_public } : c
|
||||
));
|
||||
|
||||
const { error } = await auditedSupabase.update('categories', category.id, {
|
||||
is_public: !category.is_public
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(category.is_public ? 'Category hidden' : 'Category made public');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update visibility');
|
||||
console.error(error);
|
||||
fetchCategories(); // Revert
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
@@ -321,6 +354,19 @@ export function CategoriesPage() {
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Public</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visible to users in the app
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.formData.is_public}
|
||||
onCheckedChange={(checked) => form.setField('is_public', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -359,38 +405,60 @@ export function CategoriesPage() {
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category, index) => (
|
||||
<Card key={category.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Card key={category.id} className="group flex flex-col hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="rounded-lg bg-primary/10 p-2.5">
|
||||
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{category.is_public ? 'Public' : 'Hidden'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={category.is_public}
|
||||
onCheckedChange={() => handleToggleVisibility(category)}
|
||||
className="scale-75 origin-right"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 h-10">
|
||||
{category.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex flex-col gap-0.5 mr-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
className="h-5 w-5 rounded-full"
|
||||
onClick={() => handleMove(category, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
className="h-5 w-5 rounded-full"
|
||||
onClick={() => handleMove(category, 'down')}
|
||||
disabled={index === categories.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-4xl mb-2">
|
||||
<IconPreview value={category.icon} fallback="bookmark-outline" className="text-4xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => form.openEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
@@ -398,28 +466,19 @@ export function CategoriesPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>{category.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{category.description || 'No description'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" size="sm">
|
||||
View Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -249,14 +249,19 @@ export function DarePacksPage() {
|
||||
const currentOrder = pack.sort_order ?? currentIndex;
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
await Promise.all([
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update dare pack order');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const newPacks = [...darePacks];
|
||||
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
|
||||
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
|
||||
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
|
||||
setDarePacks(newPacks);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder dare packs');
|
||||
|
||||
@@ -22,7 +22,7 @@ import { IconPreview } from '@/components/ui/icon-picker';
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
@@ -54,6 +54,7 @@ export function PacksPage() {
|
||||
|
||||
// Data state
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [packs, setPacks] = useState<QuestionPack[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
@@ -74,6 +75,7 @@ export function PacksPage() {
|
||||
is_premium: false,
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
},
|
||||
(pack) => ({
|
||||
name: pack.name,
|
||||
@@ -82,6 +84,7 @@ export function PacksPage() {
|
||||
is_premium: pack.is_premium,
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
category_id: pack.category_id || '',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -102,6 +105,12 @@ export function PacksPage() {
|
||||
setCategory(cat);
|
||||
}
|
||||
|
||||
const { data: categoriesData } = await supabase
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
setCategories(categoriesData || []);
|
||||
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
@@ -221,6 +230,7 @@ export function PacksPage() {
|
||||
is_premium: form.formData.is_premium,
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: form.formData.category_id || null,
|
||||
});
|
||||
if (error) throw error;
|
||||
packId = form.editingItem.id;
|
||||
@@ -234,7 +244,7 @@ export function PacksPage() {
|
||||
is_premium: form.formData.is_premium,
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
category_id: form.formData.category_id || null,
|
||||
sort_order: totalCount,
|
||||
})
|
||||
.select('id')
|
||||
@@ -294,6 +304,7 @@ export function PacksPage() {
|
||||
is_premium: false,
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
});
|
||||
setAiDialogOpen(false);
|
||||
setSelectedTopicIds(new Set());
|
||||
@@ -312,14 +323,19 @@ export function PacksPage() {
|
||||
const currentOrder = pack.sort_order ?? currentIndex;
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
await Promise.all([
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update pack order');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const newPacks = [...packs];
|
||||
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
|
||||
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
|
||||
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
|
||||
setPacks(newPacks);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder packs');
|
||||
@@ -391,6 +407,7 @@ export function PacksPage() {
|
||||
onSave={handleSave}
|
||||
saving={form.saving}
|
||||
isEditing={form.isEditing}
|
||||
categories={categories}
|
||||
allTopics={allTopics}
|
||||
selectedTopicIds={selectedTopicIds}
|
||||
onTopicsChange={setSelectedTopicIds}
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||
import { Crown, MessageCircle, PieChart, Sparkles, Tag } from 'lucide-react';
|
||||
|
||||
interface QuestionRow {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
intensity: number | null;
|
||||
partner_text: string | null;
|
||||
}
|
||||
|
||||
interface PackRow {
|
||||
id: string;
|
||||
name: string;
|
||||
is_premium: boolean;
|
||||
is_explicit: boolean;
|
||||
category_id: string | null;
|
||||
category?: { name: string } | { name: string }[] | null;
|
||||
}
|
||||
|
||||
interface TopicRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PackTopicRow {
|
||||
pack_id: string;
|
||||
topic_id: string;
|
||||
topics: TopicRow | TopicRow[] | null;
|
||||
}
|
||||
|
||||
interface BreakdownItem {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PackBreakdownItem {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
isPremium: boolean;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ChartDatum {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
type SegmentKey = 'all' | 'premium' | 'free';
|
||||
|
||||
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
|
||||
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
|
||||
|
||||
const formatPercent = (count: number, total: number) => (total > 0 ? Math.round((count / total) * 100) : 0);
|
||||
|
||||
const getCategoryName = (category?: PackRow['category']) => {
|
||||
if (!category) return 'Uncategorized';
|
||||
if (Array.isArray(category)) {
|
||||
return category[0]?.name || 'Uncategorized';
|
||||
}
|
||||
return category.name || 'Uncategorized';
|
||||
};
|
||||
|
||||
const normalizeTopics = (topics?: PackTopicRow['topics']) => {
|
||||
if (!topics) return [] as TopicRow[];
|
||||
return Array.isArray(topics) ? topics : [topics];
|
||||
};
|
||||
|
||||
export function QuestionAnalyticsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
totalQuestions: 0,
|
||||
premiumQuestions: 0,
|
||||
freeQuestions: 0,
|
||||
taggedQuestions: 0,
|
||||
});
|
||||
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
||||
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
free: [],
|
||||
});
|
||||
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
free: [],
|
||||
});
|
||||
const [categoryBreakdown, setCategoryBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
free: [],
|
||||
});
|
||||
const [packBreakdown, setPackBreakdown] = useState<Record<SegmentKey, PackBreakdownItem[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
free: [],
|
||||
});
|
||||
const [intensitySegment, setIntensitySegment] = useState<SegmentKey>('all');
|
||||
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
|
||||
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
|
||||
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllQuestions = async () => {
|
||||
const pageSize = 1000;
|
||||
let page = 0;
|
||||
const allQuestions: QuestionRow[] = [];
|
||||
|
||||
while (true) {
|
||||
const from = page * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('id, pack_id, intensity, partner_text')
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const batch = data || [];
|
||||
allQuestions.push(...batch);
|
||||
|
||||
if (batch.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return allQuestions;
|
||||
};
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
|
||||
supabase
|
||||
.from('question_packs')
|
||||
.select('id, name, is_premium, is_explicit, category_id, category:categories(name)'),
|
||||
supabase
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name'),
|
||||
supabase
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)'),
|
||||
fetchAllQuestions(),
|
||||
]);
|
||||
|
||||
if (packsResult.error) throw packsResult.error;
|
||||
if (topicsResult.error) throw topicsResult.error;
|
||||
if (packTopicsResult.error) throw packTopicsResult.error;
|
||||
|
||||
const packs = (packsResult.data || []) as PackRow[];
|
||||
const topics = (topicsResult.data || []) as TopicRow[];
|
||||
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
|
||||
|
||||
const packById = new Map<string, PackRow>();
|
||||
packs.forEach((pack) => {
|
||||
packById.set(pack.id, pack);
|
||||
});
|
||||
|
||||
const seedTopicCounts = () => new Map<string, BreakdownItem>(
|
||||
topics.map((topic) => [topic.id, { id: topic.id, name: topic.name, count: 0 }])
|
||||
);
|
||||
|
||||
const topicCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
|
||||
all: seedTopicCounts(),
|
||||
premium: seedTopicCounts(),
|
||||
free: seedTopicCounts(),
|
||||
};
|
||||
|
||||
const packTopicsMap = new Map<string, TopicRow[]>();
|
||||
packTopics.forEach((relation) => {
|
||||
const topicsForPack = packTopicsMap.get(relation.pack_id) || [];
|
||||
const topicsList = normalizeTopics(relation.topics);
|
||||
|
||||
if (topicsList.length === 0) return;
|
||||
|
||||
topicsForPack.push(...topicsList);
|
||||
packTopicsMap.set(relation.pack_id, topicsForPack);
|
||||
});
|
||||
|
||||
const emptyIntensityCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
const intensityCounts: Record<SegmentKey, Record<number, number>> = {
|
||||
all: { ...emptyIntensityCounts },
|
||||
premium: { ...emptyIntensityCounts },
|
||||
free: { ...emptyIntensityCounts },
|
||||
};
|
||||
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
|
||||
const categoryCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
|
||||
all: new Map(),
|
||||
premium: new Map(),
|
||||
free: new Map(),
|
||||
};
|
||||
const packCountsBySegment: Record<SegmentKey, Map<string, PackBreakdownItem>> = {
|
||||
all: new Map(),
|
||||
premium: new Map(),
|
||||
free: new Map(),
|
||||
};
|
||||
|
||||
let premiumCount = 0;
|
||||
let freeCount = 0;
|
||||
let taggedCount = 0;
|
||||
|
||||
questions.forEach((question) => {
|
||||
const pack = packById.get(question.pack_id);
|
||||
const isPremium = pack?.is_premium ?? false;
|
||||
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
|
||||
const categoryName = getCategoryName(pack?.category);
|
||||
const categoryId = pack?.category_id || 'uncategorized';
|
||||
|
||||
if (isPremium) {
|
||||
premiumCount += 1;
|
||||
} else {
|
||||
freeCount += 1;
|
||||
}
|
||||
|
||||
const intensity = question.intensity ?? 1;
|
||||
intensityCounts.all[intensity] = (intensityCounts.all[intensity] || 0) + 1;
|
||||
intensityCounts[segmentKey][intensity] = (intensityCounts[segmentKey][intensity] || 0) + 1;
|
||||
|
||||
const updatePackCounts = (key: SegmentKey) => {
|
||||
const map = packCountsBySegment[key];
|
||||
const packEntry = map.get(question.pack_id) || {
|
||||
id: question.pack_id,
|
||||
name: pack?.name || 'Unknown Pack',
|
||||
count: 0,
|
||||
isPremium: isPremium,
|
||||
category: categoryName,
|
||||
};
|
||||
packEntry.count += 1;
|
||||
map.set(question.pack_id, packEntry);
|
||||
};
|
||||
|
||||
updatePackCounts('all');
|
||||
updatePackCounts(segmentKey);
|
||||
|
||||
const updateCategoryCounts = (key: SegmentKey) => {
|
||||
const map = categoryCountsBySegment[key];
|
||||
const categoryEntry = map.get(categoryId) || {
|
||||
id: categoryId,
|
||||
name: categoryName,
|
||||
count: 0,
|
||||
};
|
||||
categoryEntry.count += 1;
|
||||
map.set(categoryId, categoryEntry);
|
||||
};
|
||||
|
||||
updateCategoryCounts('all');
|
||||
updateCategoryCounts(segmentKey);
|
||||
|
||||
const topicsForPack = packTopicsMap.get(question.pack_id) || [];
|
||||
if (topicsForPack.length > 0) {
|
||||
taggedCount += 1;
|
||||
topicsForPack.forEach((topic) => {
|
||||
const updateCounts = (map: Map<string, BreakdownItem>) => {
|
||||
const topicEntry = map.get(topic.id) || {
|
||||
id: topic.id,
|
||||
name: topic.name,
|
||||
count: 0,
|
||||
};
|
||||
topicEntry.count += 1;
|
||||
map.set(topic.id, topicEntry);
|
||||
};
|
||||
|
||||
updateCounts(topicCountsBySegment.all);
|
||||
updateCounts(topicCountsBySegment[segmentKey]);
|
||||
});
|
||||
} else {
|
||||
untaggedCounts.all += 1;
|
||||
untaggedCounts[segmentKey] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const totalQuestions = questions.length;
|
||||
const premiumData: ChartDatum[] = [
|
||||
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
|
||||
{ name: 'Free', value: freeCount, color: '#22c55e' },
|
||||
];
|
||||
|
||||
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
|
||||
name: label,
|
||||
value: intensityCounts[key][index + 1] || 0,
|
||||
color: intensityPalette[index],
|
||||
}));
|
||||
|
||||
const buildTopicData = (key: SegmentKey) => {
|
||||
const data = Array.from(topicCountsBySegment[key].values());
|
||||
if (untaggedCounts[key] > 0 || data.length === 0) {
|
||||
data.push({ id: 'untagged', name: 'Untagged', count: untaggedCounts[key] });
|
||||
}
|
||||
|
||||
data.sort((a, b) => b.count - a.count);
|
||||
return data;
|
||||
};
|
||||
|
||||
const intensityData = {
|
||||
all: buildIntensityData('all'),
|
||||
premium: buildIntensityData('premium'),
|
||||
free: buildIntensityData('free'),
|
||||
};
|
||||
|
||||
const topicData = {
|
||||
all: buildTopicData('all'),
|
||||
premium: buildTopicData('premium'),
|
||||
free: buildTopicData('free'),
|
||||
};
|
||||
|
||||
const buildCategoryData = (key: SegmentKey) =>
|
||||
Array.from(categoryCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
|
||||
const buildPackData = (key: SegmentKey) =>
|
||||
Array.from(packCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
|
||||
|
||||
const categoryData = {
|
||||
all: buildCategoryData('all'),
|
||||
premium: buildCategoryData('premium'),
|
||||
free: buildCategoryData('free'),
|
||||
};
|
||||
|
||||
const packData = {
|
||||
all: buildPackData('all'),
|
||||
premium: buildPackData('premium'),
|
||||
free: buildPackData('free'),
|
||||
};
|
||||
|
||||
setStats({
|
||||
totalQuestions,
|
||||
premiumQuestions: premiumCount,
|
||||
freeQuestions: freeCount,
|
||||
taggedQuestions: taggedCount,
|
||||
});
|
||||
setPremiumBreakdown(premiumData);
|
||||
setIntensityBreakdown(intensityData);
|
||||
setTopicBreakdown(topicData);
|
||||
setCategoryBreakdown(categoryData);
|
||||
setPackBreakdown(packData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch question analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAnalytics();
|
||||
}, []);
|
||||
|
||||
const premiumPercent = formatPercent(stats.premiumQuestions, stats.totalQuestions);
|
||||
const freePercent = formatPercent(stats.freeQuestions, stats.totalQuestions);
|
||||
const taggedPercent = formatPercent(stats.taggedQuestions, stats.totalQuestions);
|
||||
|
||||
const segmentOptions: { key: SegmentKey; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'premium', label: 'Premium' },
|
||||
{ key: 'free', label: 'Free' },
|
||||
];
|
||||
|
||||
const segmentLabelByKey: Record<SegmentKey, string> = {
|
||||
all: 'All',
|
||||
premium: 'Premium',
|
||||
free: 'Free',
|
||||
};
|
||||
|
||||
const totalBySegment: Record<SegmentKey, number> = {
|
||||
all: stats.totalQuestions,
|
||||
premium: stats.premiumQuestions,
|
||||
free: stats.freeQuestions,
|
||||
};
|
||||
|
||||
const segmentShareByKey: Record<SegmentKey, string> = {
|
||||
all: '100',
|
||||
premium: stats.totalQuestions ? ((stats.premiumQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
|
||||
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
|
||||
};
|
||||
|
||||
const intensityData = intensityBreakdown[intensitySegment];
|
||||
const tagData = topicBreakdown[tagSegment];
|
||||
const categoryData = categoryBreakdown[categorySegment];
|
||||
const packData = packBreakdown[packSegment];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<PieChart className="h-8 w-8 text-primary" />
|
||||
Question Analytics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deep dive into premium coverage, tags, categories, and intensity distribution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Questions</CardTitle>
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.totalQuestions.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Premium Questions</CardTitle>
|
||||
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.premiumQuestions.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{premiumPercent}% of all questions
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Free Questions</CardTitle>
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.freeQuestions.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{freePercent}% of all questions
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tagged Questions</CardTitle>
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.taggedQuestions.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{taggedPercent}% of all questions
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Premium vs Free</CardTitle>
|
||||
<CardDescription>
|
||||
Question count split by pack premium status
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : stats.totalQuestions === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No question data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={premiumBreakdown}
|
||||
dataKey="value"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{premiumBreakdown.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Intensity Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
How questions are spread across intensity levels
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{segmentOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={intensitySegment === option.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setIntensitySegment(option.key)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%)
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : totalBySegment[intensitySegment] === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No questions in this segment yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={intensityData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
|
||||
{intensityData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Tag Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Tags are derived from pack topics; questions in multi-tag packs count toward each tag
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{segmentOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={tagSegment === option.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTagSegment(option.key)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{segmentLabelByKey[tagSegment]}: {totalBySegment[tagSegment].toLocaleString()} questions ({segmentShareByKey[tagSegment]}%)
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : totalBySegment[tagSegment] === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions in this segment yet
|
||||
</div>
|
||||
) : tagData.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No tag data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tagData.map((item) => {
|
||||
const percentage = formatPercent(item.count, totalBySegment[tagSegment]);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-4 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium">{item.name}</p>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/70"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 text-right text-sm text-muted-foreground">
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Category Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of questions across pack categories
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{segmentOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={categorySegment === option.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCategorySegment(option.key)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{segmentLabelByKey[categorySegment]}: {totalBySegment[categorySegment].toLocaleString()} questions ({segmentShareByKey[categorySegment]}%)
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : totalBySegment[categorySegment] === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions in this segment yet
|
||||
</div>
|
||||
) : categoryData.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No category data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{categoryData.map((item) => {
|
||||
const percentage = formatPercent(item.count, totalBySegment[categorySegment]);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{percentage}% of questions</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{item.count.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">questions</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Top Packs</CardTitle>
|
||||
<CardDescription>
|
||||
Packs with the most questions right now
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{segmentOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={packSegment === option.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPackSegment(option.key)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{segmentLabelByKey[packSegment]}: {totalBySegment[packSegment].toLocaleString()} questions ({segmentShareByKey[packSegment]}%)
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : totalBySegment[packSegment] === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions in this segment yet
|
||||
</div>
|
||||
) : packData.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No pack data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{packData.slice(0, 8).map((pack) => (
|
||||
<div
|
||||
key={pack.id}
|
||||
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{pack.name}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{pack.category}</span>
|
||||
{pack.isPremium && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Premium
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{pack.count.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">questions</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -51,6 +52,7 @@ interface Question {
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[] | null;
|
||||
target_user_genders: string[] | null;
|
||||
required_props?: string[] | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
@@ -67,6 +69,8 @@ export function QuestionsPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [reviewQuestions, setReviewQuestions] = useState<Question[]>([]);
|
||||
const [reviewLoading, setReviewLoading] = useState(false);
|
||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -82,12 +86,14 @@ export function QuestionsPage() {
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[];
|
||||
target_user_genders: string[];
|
||||
required_props: string;
|
||||
}>({
|
||||
text: '',
|
||||
partner_text: '',
|
||||
intensity: 1,
|
||||
allowed_couple_genders: [],
|
||||
target_user_genders: [],
|
||||
required_props: '',
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -161,6 +167,8 @@ export function QuestionsPage() {
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
setReviewQuestions([]);
|
||||
setReviewOpen(false);
|
||||
}, [packId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -169,7 +177,14 @@ export function QuestionsPage() {
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingQuestion(null);
|
||||
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
||||
setFormData({
|
||||
text: '',
|
||||
partner_text: '',
|
||||
intensity: 1,
|
||||
allowed_couple_genders: [],
|
||||
target_user_genders: [],
|
||||
required_props: '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -181,6 +196,7 @@ export function QuestionsPage() {
|
||||
intensity: question.intensity,
|
||||
allowed_couple_genders: question.allowed_couple_genders || [],
|
||||
target_user_genders: question.target_user_genders || [],
|
||||
required_props: question.required_props?.join(', ') ?? '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -191,6 +207,12 @@ export function QuestionsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredProps = formData.required_props
|
||||
.split(/[,;\n]/)
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingQuestion) {
|
||||
@@ -200,6 +222,7 @@ export function QuestionsPage() {
|
||||
intensity: formData.intensity,
|
||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
required_props: requiredPropsValue,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
@@ -212,6 +235,7 @@ export function QuestionsPage() {
|
||||
intensity: formData.intensity,
|
||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
required_props: requiredPropsValue,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
@@ -272,6 +296,28 @@ export function QuestionsPage() {
|
||||
setAiDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleReviewOpen = async () => {
|
||||
if (!packId) return;
|
||||
|
||||
setReviewLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, created_at')
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setReviewQuestions(data || []);
|
||||
setReviewOpen(true);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load questions for review');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setReviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Selection handlers
|
||||
const toggleSelection = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
@@ -348,8 +394,12 @@ export function QuestionsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setReviewOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<Button variant="outline" onClick={handleReviewOpen} disabled={reviewLoading}>
|
||||
{reviewLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Review Questions
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
|
||||
@@ -442,6 +492,18 @@ export function QuestionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required_props">Required Props (optional)</Label>
|
||||
<Input
|
||||
id="required_props"
|
||||
value={formData.required_props}
|
||||
onChange={(e) => setFormData(d => ({ ...d, required_props: e.target.value }))}
|
||||
placeholder="e.g., vibrator, restraints"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated list of props/accessories needed for this question.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Couples (optional)</Label>
|
||||
@@ -580,7 +642,7 @@ export function QuestionsPage() {
|
||||
<ReviewQuestionsDialog
|
||||
open={reviewOpen}
|
||||
onOpenChange={setReviewOpen}
|
||||
questions={questions}
|
||||
questions={reviewQuestions}
|
||||
isExplicit={pack?.is_explicit ?? false}
|
||||
onUpdated={fetchData}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Loader2, Sparkles, Tags } from 'lucide-react';
|
||||
import { extractTopicsFromPack } from '@/lib/openai';
|
||||
|
||||
interface TopicRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PackTopicRow {
|
||||
pack_id: string;
|
||||
topic_id: string;
|
||||
}
|
||||
|
||||
interface PackRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
category?: { name: string } | { name: string }[] | null;
|
||||
}
|
||||
|
||||
interface ActiveTag {
|
||||
id: string;
|
||||
name: string;
|
||||
packCount: number;
|
||||
}
|
||||
|
||||
interface UntaggedPack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
categoryName: string;
|
||||
questionCount: number;
|
||||
}
|
||||
|
||||
const getCategoryName = (category?: PackRow['category']) => {
|
||||
if (!category) return 'Uncategorized';
|
||||
if (Array.isArray(category)) {
|
||||
return category[0]?.name || 'Uncategorized';
|
||||
}
|
||||
return category.name || 'Uncategorized';
|
||||
};
|
||||
|
||||
export function TagsPage() {
|
||||
const [activeTags, setActiveTags] = useState<ActiveTag[]>([]);
|
||||
const [untaggedPacks, setUntaggedPacks] = useState<UntaggedPack[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taggingAll, setTaggingAll] = useState(false);
|
||||
const [taggingProgress, setTaggingProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
packName: string;
|
||||
} | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
|
||||
supabase.from('topics').select('id, name').order('name'),
|
||||
supabase.from('pack_topics').select('pack_id, topic_id'),
|
||||
supabase
|
||||
.from('question_packs')
|
||||
.select('id, name, description, is_public, category:categories(name)')
|
||||
.order('name'),
|
||||
]);
|
||||
|
||||
if (topicsResult.error) throw topicsResult.error;
|
||||
if (packTopicsResult.error) throw packTopicsResult.error;
|
||||
if (packsResult.error) throw packsResult.error;
|
||||
|
||||
const topics = (topicsResult.data || []) as TopicRow[];
|
||||
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
|
||||
const packs = (packsResult.data || []) as PackRow[];
|
||||
|
||||
const topicCounts = new Map<string, number>();
|
||||
const packIdsWithTopics = new Set<string>();
|
||||
|
||||
packTopics.forEach((relation) => {
|
||||
if (!relation.topic_id || !relation.pack_id) return;
|
||||
packIdsWithTopics.add(relation.pack_id);
|
||||
topicCounts.set(relation.topic_id, (topicCounts.get(relation.topic_id) || 0) + 1);
|
||||
});
|
||||
|
||||
const activeTagList = topics
|
||||
.filter((topic) => topicCounts.has(topic.id))
|
||||
.map((topic) => ({
|
||||
id: topic.id,
|
||||
name: topic.name,
|
||||
packCount: topicCounts.get(topic.id) || 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.packCount !== a.packCount) return b.packCount - a.packCount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const untagged = packs.filter((pack) => !packIdsWithTopics.has(pack.id));
|
||||
const untaggedPackIds = untagged.map((pack) => pack.id);
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (untaggedPackIds.length > 0) {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', untaggedPackIds);
|
||||
|
||||
if (questionsError) throw questionsError;
|
||||
|
||||
questions?.forEach((question) => {
|
||||
if (!question.pack_id) return;
|
||||
questionCounts[question.pack_id] = (questionCounts[question.pack_id] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveTags(activeTagList);
|
||||
setUntaggedPacks(
|
||||
untagged.map((pack) => ({
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
description: pack.description,
|
||||
is_public: pack.is_public,
|
||||
categoryName: getCategoryName(pack.category),
|
||||
questionCount: questionCounts[pack.id] || 0,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load tags');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const eligiblePackCount = untaggedPacks.filter((pack) => pack.questionCount > 0).length;
|
||||
|
||||
const handleTagAll = async () => {
|
||||
if (taggingAll || eligiblePackCount === 0) return;
|
||||
|
||||
const eligiblePacks = untaggedPacks.filter((pack) => pack.questionCount > 0);
|
||||
const skippedNoQuestions = untaggedPacks.length - eligiblePacks.length;
|
||||
|
||||
if (eligiblePacks.length === 0) {
|
||||
toast('No untagged packs with questions to tag');
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldProceed = confirm(
|
||||
`Tag ${eligiblePacks.length} untagged pack${eligiblePacks.length !== 1 ? 's' : ''} with AI?` +
|
||||
(skippedNoQuestions > 0
|
||||
? ` ${skippedNoQuestions} pack${skippedNoQuestions !== 1 ? 's' : ''} without questions will be skipped.`
|
||||
: '')
|
||||
);
|
||||
if (!shouldProceed) return;
|
||||
|
||||
setTaggingAll(true);
|
||||
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
|
||||
|
||||
try {
|
||||
const { data: topicsData, error: topicsError } = await supabase
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
|
||||
if (topicsError) throw topicsError;
|
||||
|
||||
const existingTopics: TopicRow[] = [...(topicsData || [])];
|
||||
let taggedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (let index = 0; index < eligiblePacks.length; index += 1) {
|
||||
const pack = eligiblePacks[index];
|
||||
setTaggingProgress({
|
||||
current: index + 1,
|
||||
total: eligiblePacks.length,
|
||||
packName: pack.name,
|
||||
});
|
||||
|
||||
try {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
if (questionsError) throw questionsError;
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await extractTopicsFromPack(
|
||||
pack.name,
|
||||
pack.description,
|
||||
questions.map((question) => question.text),
|
||||
existingTopics
|
||||
);
|
||||
|
||||
if (!result.topics || result.topics.length === 0) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingByName = new Map(
|
||||
existingTopics.map((topic) => [topic.name.toLowerCase(), topic.id])
|
||||
);
|
||||
const newTopics: { name: string }[] = [];
|
||||
const topicIds: string[] = [];
|
||||
|
||||
result.topics.forEach((topic) => {
|
||||
const normalizedName = topic.name.trim();
|
||||
if (!normalizedName) return;
|
||||
|
||||
if (!topic.isNew && topic.existingTopicId) {
|
||||
topicIds.push(topic.existingTopicId);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingId = existingByName.get(normalizedName.toLowerCase());
|
||||
if (existingId) {
|
||||
topicIds.push(existingId);
|
||||
return;
|
||||
}
|
||||
|
||||
newTopics.push({ name: normalizedName });
|
||||
});
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
.from('topics')
|
||||
.insert(newTopics)
|
||||
.select('id, name');
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
createdTopics?.forEach((topic) => {
|
||||
topicIds.push(topic.id);
|
||||
existingTopics.push({ id: topic.id, name: topic.name });
|
||||
});
|
||||
}
|
||||
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
|
||||
|
||||
if (topicIds.length === 0) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
.from('pack_topics')
|
||||
.insert(topicIds.map((topicId) => ({
|
||||
pack_id: pack.id,
|
||||
topic_id: topicId,
|
||||
})));
|
||||
|
||||
if (linkError) throw linkError;
|
||||
|
||||
taggedCount += 1;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
console.error(`Failed to tag pack ${pack.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.error(`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}. ${failedCount} failed.`);
|
||||
} else {
|
||||
toast.success(
|
||||
`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}.` +
|
||||
(skippedCount > 0 ? ` ${skippedCount} skipped.` : '')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to tag all packs');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setTaggingAll(false);
|
||||
setTaggingProgress(null);
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Skeleton className="h-64" />
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Tags className="h-8 w-8 text-primary" />
|
||||
Tags
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Active tags and packs that still need topic coverage
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<Button
|
||||
onClick={handleTagAll}
|
||||
disabled={taggingAll || eligiblePackCount === 0}
|
||||
>
|
||||
{taggingAll ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{taggingAll && taggingProgress
|
||||
? `Tagging ${taggingProgress.current}/${taggingProgress.total}`
|
||||
: 'Tag All Untagged'}
|
||||
</Button>
|
||||
{taggingProgress?.packName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Working on {taggingProgress.packName}
|
||||
</p>
|
||||
)}
|
||||
{eligiblePackCount === 0 && untaggedPacks.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add questions to enable auto-tagging.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Tags</CardTitle>
|
||||
<Tags className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeTags.length.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">Tags assigned to at least one pack</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Untagged Packs</CardTitle>
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{untaggedPacks.length.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">Packs missing any tag coverage</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Tags</CardTitle>
|
||||
<CardDescription>Tag usage across question packs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeTags.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<CheckCircle2 className="h-10 w-10 mb-2" />
|
||||
No active tags yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
||||
>
|
||||
<p className="font-medium">{tag.name}</p>
|
||||
<Badge variant="secondary">
|
||||
{tag.packCount} pack{tag.packCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Untagged Packs</CardTitle>
|
||||
<CardDescription>These packs still need topics</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{untaggedPacks.length} total
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{untaggedPacks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<CheckCircle2 className="h-10 w-10 mb-2" />
|
||||
All packs are tagged
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{untaggedPacks.map((pack) => (
|
||||
<div
|
||||
key={pack.id}
|
||||
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{pack.name}</p>
|
||||
{pack.is_public ? (
|
||||
<Badge variant="outline" className="border-green-500 text-green-400">
|
||||
Published
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
)}
|
||||
{pack.questionCount === 0 && (
|
||||
<Badge variant="outline" className="border-yellow-500 text-yellow-400">
|
||||
No questions
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{pack.categoryName}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{pack.questionCount}</p>
|
||||
<p className="text-xs text-muted-foreground">questions</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface Category {
|
||||
icon: string | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
pack_count?: number;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ export interface CategoryFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -85,10 +87,12 @@ export async function createCategory(
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
sort_order: sortOrder,
|
||||
is_public: data.is_public ?? true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return created as Category;
|
||||
if (!created || created.length === 0) throw new Error('Failed to create category');
|
||||
return created[0] as Category;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +106,7 @@ export async function updateCategory(
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
is_public: data.is_public,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -132,7 +132,8 @@ export async function createPack(
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return created as Pack;
|
||||
if (!created || created.length === 0) throw new Error('Failed to create pack');
|
||||
return created[0] as Pack;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,7 +84,8 @@ export async function createQuestion(
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return created as Question;
|
||||
if (!created || created.length === 0) throw new Error('Failed to create question');
|
||||
return created[0] as Question;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +139,7 @@ export async function bulkCreateQuestions(
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
if (data) results.push(data as Question);
|
||||
if (data && data.length > 0) results.push(data[0] as Question);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
Reference in new issue
Block a user