mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(content): add catalogue review metadata
Add reversible review states, audit history, and super-admin controls for catalogue entities without changing current client visibility. Stage 30 high-risk production packs for archival review, cap generated content at non-explicit language, and preserve a checksummed pre-change production catalogue snapshot.
This commit is contained in:
1 parent
b43804d8fd
commit
94c76535a4
30 files changed
+46906
-128
No files matched your search
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
import { Archive, CheckCircle2, ClipboardCheck, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
CONTENT_STATUS_LABELS,
|
||||
type ReviewableContentTable,
|
||||
updateContentReviewStatus,
|
||||
} from '@/services/contentReviews';
|
||||
|
||||
interface ContentReviewControlProps {
|
||||
table: ReviewableContentTable;
|
||||
entityId: string;
|
||||
entityLabel: string;
|
||||
status?: ContentReviewStatus | null;
|
||||
reason?: string | null;
|
||||
onChanged: () => void | Promise<void>;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const badgeClasses: Record<ContentReviewStatus, string> = {
|
||||
unreviewed: 'border-amber-500 text-amber-500',
|
||||
allowed: 'border-green-500 text-green-500',
|
||||
archived: 'border-slate-500 text-slate-400',
|
||||
};
|
||||
|
||||
const statusIcons: Record<ContentReviewStatus, typeof ClipboardCheck> = {
|
||||
unreviewed: ClipboardCheck,
|
||||
allowed: CheckCircle2,
|
||||
archived: Archive,
|
||||
};
|
||||
|
||||
export function ContentReviewControl({
|
||||
table,
|
||||
entityId,
|
||||
entityLabel,
|
||||
status,
|
||||
reason,
|
||||
onChanged,
|
||||
compact = false,
|
||||
}: ContentReviewControlProps) {
|
||||
const { isSuperAdmin } = useAuth();
|
||||
const resolvedStatus = status ?? 'unreviewed';
|
||||
const StatusIcon = statusIcons[resolvedStatus];
|
||||
const [open, setOpen] = useState(false);
|
||||
const [nextStatus, setNextStatus] = useState<ContentReviewStatus>(resolvedStatus);
|
||||
const [nextReason, setNextReason] = useState(reason ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNextStatus(resolvedStatus);
|
||||
setNextReason(reason ?? '');
|
||||
}
|
||||
}, [open, reason, resolvedStatus]);
|
||||
|
||||
const save = async () => {
|
||||
if (!nextReason.trim()) {
|
||||
toast.error('A review reason is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateContentReviewStatus(table, entityId, nextStatus, nextReason);
|
||||
toast.success(`${entityLabel} marked ${CONTENT_STATUS_LABELS[nextStatus].toLowerCase()}`);
|
||||
setOpen(false);
|
||||
await onChanged();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update catalogue status');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${badgeClasses[resolvedStatus]} ${compact ? 'text-[10px] px-1.5 py-0' : ''}`}
|
||||
title={reason || 'No review reason recorded'}
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{CONTENT_STATUS_LABELS[resolvedStatus]}
|
||||
</Badge>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
className={compact ? 'h-7 px-2 text-xs' : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Review catalogue status for ${entityLabel}`}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review catalogue status</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records a reversible editorial decision for {entityLabel}.
|
||||
Current customer apps do not filter this status yet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`content-status-${entityId}`}>Status</Label>
|
||||
<Select
|
||||
value={nextStatus}
|
||||
onValueChange={(value) => {
|
||||
const selectedStatus = value as ContentReviewStatus;
|
||||
setNextStatus(selectedStatus);
|
||||
if (selectedStatus !== resolvedStatus) setNextReason('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`content-status-${entityId}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unreviewed">Needs review</SelectItem>
|
||||
<SelectItem value="allowed">Allowed</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`content-reason-${entityId}`}>Reason</Label>
|
||||
<Textarea
|
||||
id={`content-reason-${entityId}`}
|
||||
value={nextReason}
|
||||
onChange={(event) => setNextReason(event.target.value)}
|
||||
placeholder="Explain why this content is allowed, archived, or needs another review"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={save} disabled={saving || !nextReason.trim()}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save decision
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ const AUDITED_TABLES = [
|
||||
'question_packs',
|
||||
'questions',
|
||||
'categories',
|
||||
'dare_packs',
|
||||
'dares',
|
||||
'profiles',
|
||||
'couples',
|
||||
'responses',
|
||||
|
||||
@@ -23,8 +23,8 @@ export async function analyzeQuestionDeletions(
|
||||
}));
|
||||
|
||||
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.';
|
||||
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity.'
|
||||
: 'Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity rather than rewriting them into disguised euphemisms.';
|
||||
|
||||
const prompt = `<task>
|
||||
Identify questions that should be DELETED instead of edited.
|
||||
@@ -38,7 +38,7 @@ ${toneInstruction}
|
||||
<deletion_categories>
|
||||
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
|
||||
- redundant: minor variation with no added value
|
||||
- off-tone: violates explicit vs non-explicit rules
|
||||
- off-tone: violates the universal catalogue safety ceiling or the pack's intended tone
|
||||
- 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
|
||||
@@ -73,8 +73,8 @@ 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.';
|
||||
? 'You are a strict content safety reviewer for a couples connection app. A legacy explicit flag never permits sexual content. Mark content outside the universal safety ceiling for deletion. Always respond with valid JSON only.'
|
||||
: 'You are a strict content safety reviewer for a couples connection app. Mark sexual or explicit content for deletion. Always respond with valid JSON only.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
|
||||
@@ -9,16 +9,16 @@ export interface ImageSafetyAnalysis {
|
||||
}
|
||||
|
||||
const SAFETY_ANALYSIS_PROMPT = `<task>
|
||||
Analyze this image for content safety in the context of an adult couples intimacy app.
|
||||
Analyze this image for content safety in the context of a couples connection 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.
|
||||
This app has one universal mobile catalogue. Explicit or sexually gratifying imagery is not eligible for publication, even when it depicts consenting adults.
|
||||
</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
|
||||
- SAFE: Non-explicit relationship, romance, lifestyle, or abstract imagery appropriate for the universal catalogue
|
||||
- ADULT_CONTENT: Sexual, sexually gratifying, fetish, explicit-anatomy, or stimulation-focused imagery; not appropriate for publication
|
||||
- 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
|
||||
@@ -27,9 +27,9 @@ This app is for ADULT couples (25-45) to explore intimacy. Adult content between
|
||||
|
||||
<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
|
||||
- low: Minor flag, needs human review before publication
|
||||
- medium: Not eligible without a documented human safety decision
|
||||
- high: Inappropriate for publication and should be archived
|
||||
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
|
||||
</severity_levels>
|
||||
|
||||
@@ -40,7 +40,7 @@ Return a JSON object:
|
||||
"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
|
||||
"isAppropriate": true|false // false for ADULT_CONTENT, VIOLENCE, MINORS, ILLEGAL, UNCERTAIN, or any flagged severity
|
||||
}
|
||||
</output_format>`;
|
||||
|
||||
|
||||
@@ -22,18 +22,17 @@ export async function analyzeQuestionText(
|
||||
}));
|
||||
|
||||
const toneInstruction = isExplicit
|
||||
? 'TONE: This is an EXPLICIT pack with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT keep crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s the name), "Edge until they beg". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.'
|
||||
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
|
||||
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Replace explicit wording with a high-level proposal about curiosity, trust, anticipation, comfort, or relationship dynamics. If that would invent a materially different proposal, omit it so deletion review can archive it.'
|
||||
: 'Keep language romantic, playful, flirty, or daring without naming or describing sexual content. Replace explicit wording with a safe high-level proposal, or omit it when that would change the proposal materially.';
|
||||
|
||||
// Use different examples based on explicit/non-explicit
|
||||
const symmetricExamples = isExplicit
|
||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"'
|
||||
? 'GOOD SAFE REPLACEMENTS: "Explore a new dynamic together", "Share a private desire in your own words", "Try something daring together"'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
|
||||
|
||||
const asymmetricExamples = isExplicit
|
||||
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
||||
- GOOD: "Spank your partner" / "Be spanked by your partner"`
|
||||
? `text (The Doer): Active safe proposal (e.g., "Let your partner take the lead").
|
||||
- partner_text (The Receiver): Reciprocal safe proposal (e.g., "Take the lead with your partner").
|
||||
- GOOD: "Plan a bold surprise for your partner" / "Let your partner plan a bold surprise"`
|
||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
|
||||
- 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"`;
|
||||
@@ -66,17 +65,15 @@ Cards should be "Proposals" for specific actions, NOT interview questions.
|
||||
|
||||
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)
|
||||
- Keep both perspectives inside the same universal safety ceiling
|
||||
</question_types>
|
||||
|
||||
<rules>
|
||||
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about
|
||||
1. Preserve the core relationship intent only when it is catalogue-safe
|
||||
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
|
||||
5. Keep allowed catalogue proposals anatomy-neutral by default
|
||||
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
|
||||
@@ -102,8 +99,8 @@ Only include questions that need text improvement.
|
||||
</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.'
|
||||
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
|
||||
? 'You are a content editor for a couples connection app. A legacy explicit flag never overrides the universal safety ceiling. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.'
|
||||
: 'You are a professional content editor for a couples connection app. Improve phrasing while never outputting sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
|
||||
@@ -62,9 +62,9 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
export const TONE_LEVELS = [
|
||||
{ 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' },
|
||||
{ level: 3, label: 'Playful', description: 'Flirty anticipation, novelty & playful connection' },
|
||||
{ level: 4, label: 'Bold', description: 'Trust, leadership & suggestive relationship dynamics' },
|
||||
{ level: 5, label: 'Daring', description: 'Comfort zones, control & user-led curiosity' },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
@@ -92,30 +92,26 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
- Partner text (if present) clearly describes receiver's experience
|
||||
- No confusing or ambiguous phrasing
|
||||
|
||||
4. ANATOMICAL CONSISTENCY:
|
||||
- No mixed male/female anatomy in alternatives
|
||||
- BAD: "Finger or give your partner a handjob"
|
||||
- GOOD: Pick one activity
|
||||
4. CATALOGUE SAFETY:
|
||||
- REJECT named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
|
||||
- REJECT sexual instructions disguised with euphemisms
|
||||
- ALLOW non-graphic romance, anticipation, trust, leadership, following, novelty, and user-led curiosity
|
||||
- The highest tone changes emotional boldness, never the explicitness ceiling
|
||||
|
||||
5. PARTNER TEXT QUALITY (if applicable):
|
||||
- Engaging and enticing, not clinical
|
||||
- BAD: "Receive oral from your partner"
|
||||
- GOOD: "Let your partner pleasure you with their mouth"
|
||||
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
||||
- Describes the reciprocal perspective without adding sexual detail
|
||||
- Preserves the same safe proposal and emotional intensity
|
||||
|
||||
6. 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
|
||||
- Default: ALL couples (null)
|
||||
- Safe catalogue proposals should not require anatomical targeting
|
||||
- Flag unnecessary or exclusionary targeting
|
||||
|
||||
7. 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
|
||||
- Default: null (anyone can initiate)
|
||||
- Set initiator only when a legitimate non-anatomical product rule requires it
|
||||
- Flag unnecessary targeting
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
@@ -125,10 +121,10 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
|
||||
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
|
||||
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.',
|
||||
2: 'INTIMACY LEVEL: WARM. Focus on romantic moments, affectionate touch, and anticipation. Examples: "Slow dance in the living room", "Plan a surprise date", "Share a longer kiss", "Give your partner a relaxing massage". Romantic atmosphere without sexual instructions.',
|
||||
3: 'INTIMACY LEVEL: PLAYFUL. Focus on flirtation, novelty, private signals, playful rules, and keeping a partner guessing. Examples: "Create a private signal together", "Plan a playful surprise", "Choose a bold outfit for your partner". Suggestive relationship energy without sexual acts.',
|
||||
4: 'INTIMACY LEVEL: BOLD. Focus on trust, taking the lead, following a partner, and switching relationship dynamics. Examples: "Let your partner lead the whole date", "Set a playful rule for an evening", "Switch who makes every decision". No named fetishes, gear, sexual acts, anatomy, nudity, or arousal instructions.',
|
||||
5: 'INTIMACY LEVEL: DARING. Focus on comfort zones, control, vulnerability, and user-led curiosity. Examples: "Share something you are curious to try", "Let your partner plan a bold surprise", "Try a new dynamic together". Emotionally daring but never sexually explicit or instructional.',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -142,8 +138,10 @@ export const CORE_LANGUAGE_RULES = `
|
||||
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
|
||||
5. Keep allowed catalogue proposals anatomy-neutral and suitable for every couple by default
|
||||
6. Keep concise - ideal length is 5-12 words per question
|
||||
7. UNIVERSAL SAFETY CEILING - never name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
|
||||
8. Higher tone levels increase trust, anticipation, control, novelty, and vulnerability; they never override rule 7
|
||||
</language_rules>
|
||||
`;
|
||||
|
||||
@@ -164,32 +162,32 @@ export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
|
||||
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>
|
||||
<constraint>Romantic and suggestive but not sexual or instructional. Build anticipation without naming sexual content.</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>
|
||||
<role>Generate playful, flirty activities about anticipation and novelty.</role>
|
||||
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
|
||||
<style>Playful, suggestive, and adventurous without supplying the sexual act.</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.
|
||||
4: `You are an expert content writer for Sauci, a couples connection 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>
|
||||
<role>Generate bold activities about trust, leadership, following, and switching dynamics.</role>
|
||||
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
|
||||
<style>Confident, suggestive, and adventurous without explicit content.</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.
|
||||
5: `You are an expert content writer for Sauci, a couples connection 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>
|
||||
<role>Generate emotionally daring activities about comfort zones, control, vulnerability, and user-led curiosity.</role>
|
||||
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions. Never turn curiosity into a developer-supplied sexual example.</constraint>
|
||||
<style>Bold and intriguing while preserving the universal safety ceiling.</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>`,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const create = vi.fn();
|
||||
|
||||
vi.mock('@/hooks/useAiConfig', () => ({
|
||||
getCachedAiConfig: () => null,
|
||||
preloadAiConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getOpenAI: () => ({ chat: { completions: { create } } }),
|
||||
getModel: () => 'test-model',
|
||||
getTemperature: (_purpose?: string, fallback?: number) => fallback ?? 0,
|
||||
}));
|
||||
|
||||
import { SYSTEM_MESSAGES, TONE_INSTRUCTIONS } from './config';
|
||||
import { analyzeQuestionDeletions } from './analyzers/deletions';
|
||||
import { describeImage } from './analyzers/media';
|
||||
import { analyzeQuestionText } from './analyzers/text';
|
||||
import { generateQuestions } from './generators/questions';
|
||||
import { polishContent } from './tools/polish';
|
||||
|
||||
const prohibitedLegacyPhrases = [
|
||||
'adult content is allowed',
|
||||
'adult content is expected and acceptable',
|
||||
'do not over-sanitize',
|
||||
'keep explicit terms',
|
||||
];
|
||||
|
||||
function serializedMessages(): string {
|
||||
const request = create.mock.calls[create.mock.calls.length - 1]?.[0];
|
||||
return JSON.stringify(request?.messages ?? []).toLowerCase();
|
||||
}
|
||||
|
||||
describe('universal catalogue AI safety ceiling', () => {
|
||||
beforeEach(() => {
|
||||
create.mockReset();
|
||||
create.mockResolvedValue({
|
||||
choices: [{ message: { content: '{"polished":"safe","suggestions":[],"deletions":[]}' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every generation level non-explicit', () => {
|
||||
for (const level of [1, 2, 3, 4, 5] as const) {
|
||||
const instructions = `${TONE_INSTRUCTIONS[level]} ${SYSTEM_MESSAGES[level]}`.toLowerCase();
|
||||
expect(instructions).toMatch(/no sexual|not sexual|never sexually explicit/);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let the legacy explicit flag weaken polish safety', async () => {
|
||||
await polishContent('legacy explicit copy', 'question', true);
|
||||
|
||||
const messages = serializedMessages();
|
||||
expect(messages).toContain('legacy explicit flag');
|
||||
expect(messages).toContain('universal catalogue safety ceiling');
|
||||
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||
});
|
||||
|
||||
it('keeps daring generation and the legacy crude option below the same ceiling', async () => {
|
||||
await generateQuestions('Daring connection', 2, 5, undefined, undefined, true);
|
||||
|
||||
const messages = serializedMessages();
|
||||
expect(messages).toContain('critical universal safety ceiling');
|
||||
expect(messages).toContain('legacy crude-language option');
|
||||
expect(messages).toContain('never more explicit content');
|
||||
});
|
||||
|
||||
it('does not let the legacy explicit flag weaken text repair safety', async () => {
|
||||
await analyzeQuestionText([{ id: 'question-1', text: 'legacy explicit copy' }], true);
|
||||
|
||||
const messages = serializedMessages();
|
||||
expect(messages).toContain('legacy explicit flag');
|
||||
expect(messages).toContain('universal catalogue safety ceiling');
|
||||
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||
});
|
||||
|
||||
it('routes legacy explicit content toward deletion', async () => {
|
||||
await analyzeQuestionDeletions([{ id: 'question-1', text: 'legacy explicit copy' }], true);
|
||||
|
||||
const messages = serializedMessages();
|
||||
expect(messages).toContain('legacy explicit flag');
|
||||
expect(messages).toContain('delete developer-authored sexual acts');
|
||||
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||
});
|
||||
|
||||
it('classifies adult imagery as ineligible rather than acceptable', async () => {
|
||||
await describeImage('data:image/jpeg;base64,test');
|
||||
|
||||
const messages = serializedMessages();
|
||||
expect(messages).toContain('adult_content');
|
||||
expect(messages).toContain('not appropriate for publication');
|
||||
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||
});
|
||||
});
|
||||
@@ -71,11 +71,11 @@ export async function suggestCategories(
|
||||
: 'None';
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Include bold, spicy, and explicitly intimate categories (NSFW is allowed).'
|
||||
? 'This legacy explicit flag does not override the universal safety ceiling. Keep categories suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
|
||||
? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
|
||||
: '';
|
||||
|
||||
// Sanitize inspiration to prevent prompt injection
|
||||
|
||||
@@ -50,7 +50,7 @@ Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQues
|
||||
<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)'}
|
||||
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
|
||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||
- Requested question count: ${requestedCount}
|
||||
</pack_context>
|
||||
@@ -77,7 +77,7 @@ Calibrate your scores knowing these weights. A low compliance score has the most
|
||||
|
||||
<duplicate_detection>
|
||||
Two questions are duplicates if they:
|
||||
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside")
|
||||
- Describe the same core activity/action (e.g., "Plan a surprise date" vs "Arrange a mystery date")
|
||||
- Differ only in minor phrasing variations
|
||||
- Would feel repetitive if both appeared in the same pack
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ export async function generatePack(
|
||||
const openai = getOpenAI();
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Include bold, spicy, and uncensored themes (NSFW is allowed).'
|
||||
? 'This legacy explicit flag does not override the universal safety ceiling. Keep the theme suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
|
||||
? 'LEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
|
||||
: '';
|
||||
|
||||
// Sanitize inspiration to prevent prompt injection
|
||||
@@ -105,11 +105,11 @@ export async function suggestPacks(
|
||||
: 'None';
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Include bold, spicy, and uncensored pack ideas (NSFW is allowed).'
|
||||
? 'This legacy explicit flag does not override the universal safety ceiling. Keep ideas suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
|
||||
? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
|
||||
: '';
|
||||
|
||||
// Sanitize inspiration to prevent prompt injection
|
||||
|
||||
@@ -19,10 +19,8 @@ function buildQuestionPrompt(
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string
|
||||
): string {
|
||||
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.'
|
||||
? '\n\nLEGACY CRUDE-LANGUAGE OPTION: Do not follow it. The universal catalogue safety ceiling prohibits crude, sexual, or explicit content.'
|
||||
: '';
|
||||
|
||||
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
|
||||
@@ -41,31 +39,20 @@ function buildQuestionPrompt(
|
||||
|
||||
// Examples based on tone level
|
||||
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: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
|
||||
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
|
||||
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Share a longer kiss".'
|
||||
: 'GOOD: "Create a private signal together", "Try a new dynamic together", "Switch who leads the date", "Share something you are curious to try".';
|
||||
|
||||
const asymmetricExamples = tone <= 2
|
||||
? `Examples:
|
||||
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
|
||||
- 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: "Tie your partner up" → partner_text: "Get tied up 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: "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"`;
|
||||
: `Examples:
|
||||
- text: "Plan a bold surprise for your partner" → partner_text: "Let your partner plan a bold surprise"
|
||||
- text: "Lead your partner through a mystery date" → partner_text: "Follow your partner through a mystery date"
|
||||
- text: "Choose a playful rule for your partner" → partner_text: "Follow a playful rule chosen by your partner"`;
|
||||
|
||||
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: Avoid extreme kinks or hardcore content unless specifically requested.';
|
||||
const explicitWarning = '\n\nCRITICAL UNIVERSAL SAFETY CEILING: Reject any request to generate named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Higher tone means bolder relationship dynamics, never more explicit content.';
|
||||
|
||||
const descriptionContext = packDescription
|
||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||
@@ -90,8 +77,8 @@ ${toneInstruction}${explicitWarning}${crudeLangInstruction}
|
||||
${CORE_LANGUAGE_RULES}
|
||||
|
||||
<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..."
|
||||
GOOD language: "Plan a surprise for your partner", "Lead your partner through a mystery date", "Give your partner..."
|
||||
BAD language: "Plan a surprise for me" (who is "me"?), "Let me lead you", "I want you to..."
|
||||
The card reader is the DOER. Their partner is "your partner".
|
||||
</examples>
|
||||
|
||||
@@ -114,13 +101,13 @@ Both questions in a pair should have the SAME inverse_pair_id (a unique string p
|
||||
|
||||
Example pair (same inverse_pair_id "pair_1"):
|
||||
Question 1 (primary):
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
text: "Plan a bold surprise for your partner"
|
||||
partner_text: "Let your partner plan a bold surprise"
|
||||
inverse_pair_id: "pair_1"
|
||||
|
||||
Question 2 (inverse):
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
text: "Let your partner plan a bold surprise"
|
||||
partner_text: "Plan a bold surprise for your partner"
|
||||
inverse_pair_id: "pair_1"
|
||||
|
||||
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
|
||||
@@ -135,7 +122,7 @@ Return a JSON object with this exact structure:
|
||||
{
|
||||
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
||||
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
|
||||
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
|
||||
"requires_props": string[]|null, // Optional: ordinary items needed (e.g., ["playing cards", "picnic blanket"])
|
||||
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
|
||||
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
||||
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
|
||||
|
||||
@@ -52,7 +52,7 @@ Review each generated question against our quality guidelines.
|
||||
<pack_context>
|
||||
- Pack Name: "${packContext.name}"
|
||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
||||
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
|
||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||
</pack_context>
|
||||
|
||||
@@ -63,7 +63,7 @@ ${REVIEW_GUIDELINES}
|
||||
<verdict_rules>
|
||||
- PASS: All scores >= 7, no major issues
|
||||
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
||||
- REJECT: Any score < 5, or major violations (mixed anatomy, severe guideline violation)
|
||||
- REJECT: Any score < 5, any sexual/explicit content, or another major guideline violation
|
||||
</verdict_rules>
|
||||
|
||||
<questions_to_review>
|
||||
@@ -104,7 +104,7 @@ Review ALL questions against ALL 7 criteria. Be thorough but fair - only flag/re
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a quality assurance reviewer for a couples intimacy app. You evaluate generated questions for guideline compliance, creativity, clarity, and accuracy. Be thorough but fair. Always respond with valid JSON only.',
|
||||
content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
@@ -213,7 +213,7 @@ Compare ${candidates.length} different sets of generated questions from differen
|
||||
<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)'}
|
||||
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
|
||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||
</pack_context>
|
||||
|
||||
@@ -270,7 +270,7 @@ Select the best set and review ALL questions in that set against ALL 7 criteria.
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a quality assurance reviewer for a couples intimacy app. You compare multiple AI-generated question sets and select the best one, then provide detailed reviews. Be thorough but fair. Always respond with valid JSON only.',
|
||||
content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Compare candidate sets and always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
|
||||
@@ -18,8 +18,8 @@ export async function polishContent(
|
||||
const openai = getOpenAI();
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Use nuanced language: tasteful phrasing for general acts ("have sex", "perform oral") but crude/specific terms when relevant to the activity ("cum on", "cock ring", "edge"). Do not over-sanitize - keep explicit terms that are essential to the activity. NEVER change "cum" to "come" - they have different meanings.'
|
||||
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
|
||||
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Remove named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. If preserving the original action would violate this rule, return a safe high-level relationship proposal instead.'
|
||||
: 'Keep it romantic, playful, and suggestive without naming or describing sexual content. If the original text is explicit, return a safe high-level relationship proposal instead.';
|
||||
|
||||
const contextMap: Record<PolishType, string> = {
|
||||
question: 'a question for a couples app',
|
||||
@@ -37,28 +37,24 @@ export async function polishContent(
|
||||
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
|
||||
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
|
||||
- BAD: "Do you want to have...", "Have you ever thought about...".
|
||||
2. ACCURACY: Do NOT rewrite the text effectively changing the action.
|
||||
2. ACCURACY: Preserve the original relationship intent when it is catalogue-safe.
|
||||
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
|
||||
- DO NOT change it to something unrelated like "Go to the movies".
|
||||
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
|
||||
3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
|
||||
4. ANATOMICAL CONSISTENCY: NEVER combine male-specific and female-specific acts as alternatives.
|
||||
- BAD: "Finger or give your partner a handjob" (requires different anatomy)
|
||||
- BAD: "Suck their cock or eat them out" (male vs female anatomy)
|
||||
- If text has mixed anatomy alternatives, keep ONE activity and remove the incompatible one.
|
||||
- If the core action is explicit, replace it with a high-level proposal about curiosity, trust, anticipation, or relationship dynamics.
|
||||
3. TONE: Engaging, romantic, flirty, or daring, but never sexual or instructional.
|
||||
4. UNIVERSAL SAFETY CEILING: Do not name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity.
|
||||
`;
|
||||
fewShotExamples = `
|
||||
=== FEW-SHOT EXAMPLES ===
|
||||
${type === 'question' ? `
|
||||
"Would you want to give me a massage?" -> "Give your partner a sensual massage"
|
||||
"Have you ever thought about sex in public?" -> "Have sex somewhere you might get caught"
|
||||
"maybe we could try using a blindfold sometime" -> "Blindfold your partner and tease their senses"
|
||||
"cum on partner" -> "Cum on your partner" (keep "cum", don't change to "come")
|
||||
"Would you want to give me a massage?" -> "Give your partner a relaxing massage"
|
||||
"Have you ever thought about trying something daring?" -> "Try something daring with your partner"
|
||||
"maybe we could explore a new dynamic sometime" -> "Explore a new dynamic with your partner"
|
||||
"describe a private fantasy" -> "Share a private desire in your own words"
|
||||
` : `
|
||||
"Receive a massage from your partner" -> "Let your partner's hands work the tension from your body"
|
||||
"Get oral from your partner" -> "Let your partner pleasure you with their mouth"
|
||||
"Be tied up" -> "Let your partner tie you up and surrender control"
|
||||
"Moan for your partner" -> "Let your partner make you moan"
|
||||
"Let your partner plan the evening" -> "Let your partner take the lead for an evening"
|
||||
"Try something new together" -> "Explore a new dynamic with your partner"
|
||||
"Tell your partner what you want" -> "Share a private desire in your own words"
|
||||
`}
|
||||
=== END EXAMPLES ===
|
||||
`;
|
||||
@@ -79,8 +75,8 @@ ${explicitInstruction}
|
||||
<rules>
|
||||
${additionalRules}
|
||||
- Make it concise, engaging, and grammatically correct
|
||||
- Maintain the original intent and meaning
|
||||
- Do NOT change the core action - only improve phrasing
|
||||
- Preserve the original intent only when it stays inside the universal safety ceiling
|
||||
- Replace explicit core actions with safe, high-level relationship proposals
|
||||
</rules>
|
||||
|
||||
${fewShotExamples}
|
||||
@@ -96,7 +92,7 @@ ${fewShotExamples}
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a professional editor for a couples app. Improve the copy while maintaining the tone. Always respond with valid JSON only.',
|
||||
content: 'You are a professional editor for a couples connection app. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
export const CONTENT_STATUS_LABELS: Record<ContentReviewStatus, string> = {
|
||||
unreviewed: 'Needs review',
|
||||
allowed: 'Allowed',
|
||||
archived: 'Archived',
|
||||
};
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Category {
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_pack_count?: number;
|
||||
dare_pack_count?: number;
|
||||
}
|
||||
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
onToggleVisibility: (category: Category) => void;
|
||||
onReviewChanged: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility, onReviewChanged }: SortableCategoryCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentReviewControl
|
||||
table="categories"
|
||||
entityId={category.id}
|
||||
entityLabel={`category “${category.name}”`}
|
||||
status={category.content_status}
|
||||
reason={category.content_review_reason}
|
||||
onChanged={onReviewChanged}
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
@@ -579,6 +594,7 @@ export function CategoriesPage() {
|
||||
onEdit={form.openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
onReviewChanged={fetchCategories}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -54,6 +56,8 @@ interface DarePack {
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
dare_count?: number;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
interface DarePackFormData {
|
||||
@@ -511,6 +515,15 @@ export function DarePacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ContentReviewControl
|
||||
table="dare_packs"
|
||||
entityId={pack.id}
|
||||
entityLabel={`dare pack “${pack.name}”`}
|
||||
status={pack.content_status}
|
||||
reason={pack.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -37,6 +37,8 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Dare {
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
created_at: string | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
// Duration options for dares
|
||||
@@ -439,6 +443,7 @@ export function DaresPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Dare</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-24">Duration</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -462,6 +467,17 @@ export function DaresPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{dare.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContentReviewControl
|
||||
table="dares"
|
||||
entityId={dare.id}
|
||||
entityLabel={`dare ${((page - 1) * pageSize) + index + 1}`}
|
||||
status={dare.content_status}
|
||||
reason={dare.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dare.suggested_duration_hours ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
||||
@@ -14,6 +14,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -43,6 +45,8 @@ interface QuestionPack {
|
||||
created_at: string | null;
|
||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_count?: number;
|
||||
topics?: Topic[];
|
||||
}
|
||||
@@ -513,6 +517,15 @@ export function PacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ContentReviewControl
|
||||
table="question_packs"
|
||||
entityId={pack.id}
|
||||
entityLabel={`pack “${pack.name}”`}
|
||||
status={pack.content_status}
|
||||
reason={pack.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
interface QuestionPack {
|
||||
id: string;
|
||||
@@ -70,6 +72,8 @@ interface Question {
|
||||
created_at: string | null;
|
||||
question_type?: QuestionType | null;
|
||||
config?: QuestionConfig | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_DURATION = 60;
|
||||
@@ -982,6 +986,7 @@ export function QuestionsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-20">Inverse</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContentReviewControl
|
||||
table="questions"
|
||||
entityId={question.id}
|
||||
entityLabel={`question ${((page - 1) * pageSize) + index + 1}`}
|
||||
status={question.content_status}
|
||||
reason={question.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
||||
|
||||
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
|
||||
|
||||
vi.mock('@/hooks/useAuditedSupabase', () => ({
|
||||
auditedSupabase: { update },
|
||||
}));
|
||||
|
||||
import { updateContentReviewStatus } from './contentReviews';
|
||||
|
||||
describe('catalogue review labels', () => {
|
||||
it('uses archive language for reversible removals', () => {
|
||||
expect(CONTENT_STATUS_LABELS).toEqual({
|
||||
unreviewed: 'Needs review',
|
||||
allowed: 'Allowed',
|
||||
archived: 'Archived',
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
update.mockReset();
|
||||
update.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
it('writes a trimmed, audited review decision', async () => {
|
||||
await updateContentReviewStatus(
|
||||
'question_packs',
|
||||
'pack-1',
|
||||
'archived',
|
||||
' Store compliance review ',
|
||||
);
|
||||
|
||||
expect(update).toHaveBeenCalledWith('question_packs', 'pack-1', {
|
||||
content_status: 'archived',
|
||||
content_review_reason: 'Store compliance review',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty reason before writing', async () => {
|
||||
await expect(
|
||||
updateContentReviewStatus('questions', 'question-1', 'allowed', ' '),
|
||||
).rejects.toThrow('A review reason is required');
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
||||
|
||||
export type ReviewableContentTable =
|
||||
| 'categories'
|
||||
| 'question_packs'
|
||||
| 'questions'
|
||||
| 'dare_packs'
|
||||
| 'dares';
|
||||
|
||||
export async function updateContentReviewStatus(
|
||||
table: ReviewableContentTable,
|
||||
id: string,
|
||||
status: ContentReviewStatus,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const normalizedReason = reason.trim();
|
||||
if (!normalizedReason) {
|
||||
throw new Error('A review reason is required');
|
||||
}
|
||||
|
||||
const { error } = await auditedSupabase.update(table, id, {
|
||||
content_status: status,
|
||||
content_review_reason: normalizedReason,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
Reference in new issue
Block a user