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:
jjbrunton committed 2026-01-10 19:24:06 +00:00
1 parent b0f1700e40
commit 4e40b92b16
71 files changed
+10511 -1008

No files matched your search

@@ -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 || [];
}
+73 -2
View File
@@ -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,
};
}
}
+89
View File
@@ -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 || [];
}
+52 -44
View File
@@ -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;
+68 -42
View File
@@ -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;