// =============================================================================
// Question Generation Functions
// Generate questions for packs with various tone/intensity settings
// =============================================================================
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';
/**
* Build the question generation prompt
*/
function buildQuestionPrompt(
packName: string,
count: number,
intensity: number | undefined,
tone: ToneLevel,
packDescription?: string,
existingQuestions?: string[],
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.'
: '';
// 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 inspirationInstruction = sanitizedInspiration
? `\n\n\n${sanitizedInspiration}\n\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 appropriate for the tone.';
const toneInstruction = TONE_INSTRUCTIONS[tone];
// 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".';
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"`;
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 descriptionContext = packDescription
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
: '';
const existingQuestionsContext = existingQuestions && existingQuestions.length > 0
? `\n\nEXISTING QUESTIONS IN THIS PACK (DO NOT DUPLICATE OR CREATE SIMILAR VARIATIONS):
${existingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
CRITICAL: Generate completely NEW and DIFFERENT questions. Do not repeat any of the above questions or create minor variations of them. Each new question must offer a genuinely distinct activity or experience.`
: '';
return `
Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
${descriptionContext}${inspirationInstruction}${existingQuestionsContext}
${INTENSITY_GUIDE_SHORT}
${intensityInstruction}
${toneInstruction}${explicitWarning}${crudeLangInstruction}
${CORE_LANGUAGE_RULES}
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".
1. SYMMETRIC Activities (partner_text = null, no inverse_pair_id):
- Both partners do the same thing together
- ${symmetricExamples}
2. ASYMMETRIC Actions (requires partner_text AND inverse pair):
- One partner does something TO/FOR the other, or roles differ
- text = what the INITIATOR does
- partner_text = what the RECEIVER does/experiences
- ${asymmetricExamples}
- CRITICAL: Every asymmetric question MUST have its inverse created as a separate question
For EVERY asymmetric question, you MUST create its inverse as a separate question.
Both questions in a pair should have the SAME inverse_pair_id (a unique string per pair).
Example pair (same inverse_pair_id "pair_1"):
Question 1 (primary):
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_pair_id: "pair_1"
Question 2 (inverse):
text: "Be spanked by your partner"
partner_text: "Spank 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.
Use simple pair IDs like "pair_1", "pair_2", etc.
Symmetric questions should have inverse_pair_id: null.
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"])
"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
}
]
}
`;
}
/**
* Generate questions using the default generation model
*/
export async function generateQuestions(
packName: string,
count: number = 10,
intensity?: number,
tone: ToneLevel = 3,
packDescription?: string,
existingQuestions?: string[],
crudeLang: boolean = false,
inspiration?: string
): Promise {
const openai = getOpenAI();
const prompt = buildQuestionPrompt(
packName,
count,
intensity,
tone,
packDescription,
existingQuestions,
crudeLang,
inspiration
);
const response = await openai.chat.completions.create({
model: getModel('generate'),
messages: [
{
role: 'system',
content: SYSTEM_MESSAGES[tone],
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.questions as GeneratedQuestion[];
}
/**
* Generate questions using a specific model
* Used by council generation for parallel model comparison
*/
export async function generateQuestionsWithModel(
model: string,
packName: string,
count: number,
intensity: number | undefined,
tone: ToneLevel,
packDescription: string | undefined,
existingQuestions: string[] | undefined,
crudeLang: boolean,
inspiration: string | undefined,
temperature?: number
): Promise {
const openai = getOpenAI();
const prompt = buildQuestionPrompt(
packName,
count,
intensity,
tone,
packDescription,
existingQuestions,
crudeLang,
inspiration
);
const response = await openai.chat.completions.create({
model: model,
messages: [
{
role: 'system',
content: SYSTEM_MESSAGES[tone],
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: temperature ?? getTemperature('generate', 0.9),
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.questions as GeneratedQuestion[];
}