mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add drag-and-drop reordering to categories page
- Replace up/down chevron buttons with @dnd-kit drag handles - Update pack generation and question review commands - Fix MCP bridge and add supabase package.json for Docker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e05105bb0
commit
3009e80dd4
13 files changed
+1124
-1280
No files matched your search
@@ -0,0 +1,271 @@
|
||||
---
|
||||
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
|
||||
argument-hint: <category name> [--all] [--explicit] [--apply] [--limit=100]
|
||||
description: Review and automatically fix issues with questions (text, targeting, intensity, props, duplicates)
|
||||
---
|
||||
|
||||
# Question Fix Agent
|
||||
|
||||
Fix questions in category: $ARGUMENTS
|
||||
|
||||
## Task Overview
|
||||
|
||||
Analyze questions across all packs in a category and apply fixes for issues across 5 dimensions:
|
||||
1. **Text Phrasing** - Improve wording, remove wishy-washy language
|
||||
2. **Audience Targeting** - Fix couple/initiator gender targeting
|
||||
3. **Intensity Levels** - Correct misassigned intensity ratings
|
||||
4. **Required Props** - Identify missing physical props/accessories
|
||||
5. **Deletions** - Remove true duplicates, unsafe, or broken questions
|
||||
|
||||
## Step 1: Parse Arguments
|
||||
|
||||
Extract from $ARGUMENTS:
|
||||
- **Category identifier**: Category name (partial match) OR category ID (UUID)
|
||||
- **--all**: Flag indicating the agent should work through all categories sequentially
|
||||
- **--explicit**: Flag indicating this is an explicit/adult category (auto-detected from category.is_explicit if not specified)
|
||||
- **--apply**: If set, apply fixes automatically after review. If not set, just show suggestions.
|
||||
- **--limit**: Max questions to analyze per category (default: 100)
|
||||
|
||||
## Step 2: Fetch Data
|
||||
|
||||
Query the database to get the category and its questions:
|
||||
|
||||
```sql
|
||||
-- Get category info
|
||||
SELECT id, name
|
||||
FROM categories
|
||||
WHERE id::text = '<category_id>' OR name ILIKE '%<category_name>%'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Get all packs in the category
|
||||
SELECT p.id, p.name, p.is_explicit
|
||||
FROM question_packs p
|
||||
WHERE p.category_id = '<category_id>'
|
||||
ORDER BY p.name;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Get questions for all packs in the category
|
||||
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, p.name as pack_name
|
||||
FROM questions q
|
||||
JOIN question_packs p ON q.pack_id = p.id
|
||||
WHERE p.category_id = '<category_id>' AND q.deleted_at IS NULL
|
||||
ORDER BY p.name, q.created_at
|
||||
LIMIT <limit>;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Get existing props catalog for consistency
|
||||
SELECT DISTINCT unnest(required_props) as prop
|
||||
FROM questions
|
||||
WHERE required_props IS NOT NULL
|
||||
ORDER BY prop;
|
||||
```
|
||||
|
||||
If **--all** flag is set, first list all categories:
|
||||
```sql
|
||||
SELECT id, name, is_explicit FROM categories ORDER BY name;
|
||||
```
|
||||
Then process each category sequentially.
|
||||
|
||||
## Step 3: Analyze Each Question
|
||||
|
||||
For each question, evaluate against these criteria:
|
||||
|
||||
### 3.1 Text Analysis
|
||||
|
||||
**Language Rules:**
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
|
||||
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
- No time-specific words - NO "tonight", "now", "today", "right now"
|
||||
- Keep concise - ideal length is 5-12 words
|
||||
|
||||
**Tone Rules (based on is_explicit):**
|
||||
|
||||
For EXPLICIT packs:
|
||||
- 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)
|
||||
- NEVER sanitize "cum" to "come" - they have different meanings
|
||||
|
||||
For NON-EXPLICIT packs:
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
- Flag explicit content for removal
|
||||
|
||||
**Partner Text Rules:**
|
||||
- NULL for symmetric activities (both partners do the same thing together)
|
||||
- FILLED for asymmetric activities (one does something to/for the other)
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER experiences
|
||||
- Make partner_text APPEALING - frame receiver's experience positively
|
||||
- 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)
|
||||
|
||||
### 3.2 Intensity Analysis
|
||||
|
||||
Check if current intensity matches the activity:
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
| 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 |
|
||||
|
||||
### 3.3 Targeting Analysis
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) 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)
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
|
||||
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
|
||||
### 3.4 Props Analysis
|
||||
|
||||
**Rules:**
|
||||
- Props are physical items/accessories, not body parts or acts
|
||||
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
|
||||
- Reuse existing prop names from the catalog when possible
|
||||
- If no props are required, set to null
|
||||
|
||||
### 3.5 Deletion Analysis
|
||||
|
||||
**Deletion Categories:**
|
||||
- `duplicate`: Same `text` AND same initiator as another question
|
||||
- `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 too broken to repair without inventing content
|
||||
- `off-topic`: Not relevant to a couples intimacy app
|
||||
|
||||
**CRITICAL: Inverse Questions are NOT Duplicates**
|
||||
|
||||
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
|
||||
|
||||
```
|
||||
Question A:
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
|
||||
Question B (INVERSE - NOT A DUPLICATE):
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
```
|
||||
|
||||
These are intentionally paired so both partners get asked about both roles. When reviewing:
|
||||
- If you see two questions where one's `text` matches the other's `partner_text`, they are a VALID INVERSE PAIR
|
||||
- Do NOT flag inverse pairs as duplicates or redundant
|
||||
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`) with the SAME initiator role
|
||||
|
||||
**Rules:**
|
||||
- Prefer edits over deletions when possible
|
||||
- For true duplicates: delete the weaker/less specific version
|
||||
- Do NOT delete for minor wording issues
|
||||
|
||||
## Step 4: Output Results
|
||||
|
||||
Present findings grouped by fix type:
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Issues Found | Will Fix |
|
||||
|----------|--------------|----------|
|
||||
| Text fixes | X | X |
|
||||
| Intensity fixes | X | X |
|
||||
| Targeting fixes | X | X |
|
||||
| Props fixes | X | X |
|
||||
| Deletions | X | X |
|
||||
| No changes needed | X | - |
|
||||
|
||||
### Detailed Changes
|
||||
|
||||
For each question needing changes, show:
|
||||
|
||||
```
|
||||
**Question ID:** <id>
|
||||
**Current:** <text> / <partner_text or null>
|
||||
**Issues:**
|
||||
- Text: <issue description>
|
||||
- Intensity: <current> -> <suggested> (<reason>)
|
||||
- Targeting: <issue description>
|
||||
- Props: <current> -> <suggested>
|
||||
- DELETE: <category> - <reason>
|
||||
|
||||
**Fix:**
|
||||
- Text: "<new text>"
|
||||
- Partner Text: "<new partner_text>" or null
|
||||
- Intensity: <new intensity>
|
||||
- Couple Targets: <new allowed_couple_genders or null>
|
||||
- Initiator Targets: <new target_user_genders or null>
|
||||
- Required Props: <new required_props or null>
|
||||
```
|
||||
|
||||
## Step 5: Apply Fixes (if --apply flag)
|
||||
|
||||
If --apply flag is set, execute the SQL updates:
|
||||
|
||||
```sql
|
||||
-- Text/Partner Text/Intensity fixes
|
||||
UPDATE questions SET
|
||||
text = '<new_text>',
|
||||
partner_text = '<new_partner_text>',
|
||||
intensity = <new_intensity>
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Targeting fixes
|
||||
UPDATE questions SET
|
||||
allowed_couple_genders = ARRAY['...'] or NULL,
|
||||
target_user_genders = ARRAY['...'] or NULL
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Props fixes
|
||||
UPDATE questions SET
|
||||
required_props = ARRAY['...'] or NULL
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Deletions (soft delete)
|
||||
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
|
||||
```
|
||||
|
||||
After applying, report:
|
||||
- X questions updated
|
||||
- X questions deleted
|
||||
- Any errors encountered
|
||||
|
||||
If --apply is NOT set, output the SQL statements for manual review.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/fix-questions "Kink" # Review all questions in "Kink" category
|
||||
/fix-questions "Romantic Classics" --apply # Review and apply fixes to category
|
||||
/fix-questions --all # Process all categories sequentially
|
||||
/fix-questions --all --apply # Process and fix all categories
|
||||
/fix-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask for clarification if category name matches multiple categories
|
||||
- DO group similar issues together for batch fixes
|
||||
- DO prioritize safety issues (delete unsafe content first)
|
||||
- DO preserve the core intent of questions when suggesting text fixes
|
||||
- DO verify inverse pairs exist before flagging duplicates
|
||||
- DON'T change questions that are already well-formed
|
||||
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
|
||||
- DON'T create new prop names when existing ones match
|
||||
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
|
||||
@@ -15,8 +15,9 @@ First, read the pack generation guidelines:
|
||||
|
||||
## Current State
|
||||
|
||||
- Existing categories: !`mcp__sauci-prod__execute_sql` with query: SELECT name, id FROM categories ORDER BY sort_order
|
||||
- Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order
|
||||
- Existing packs in target category: Will query once category is identified
|
||||
- Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop
|
||||
|
||||
## Task
|
||||
|
||||
@@ -38,7 +39,12 @@ Extract from $ARGUMENTS:
|
||||
- What intensity levels are covered?
|
||||
- Where does this new pack fit in the progression?
|
||||
|
||||
2. If new category, establish the progression plan:
|
||||
2. Check if category is explicit:
|
||||
```sql
|
||||
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
|
||||
```
|
||||
|
||||
3. If new category, establish the progression plan:
|
||||
- What will the beginner pack cover?
|
||||
- What will intermediate cover?
|
||||
- What will advanced cover?
|
||||
@@ -47,19 +53,46 @@ Extract from $ARGUMENTS:
|
||||
|
||||
Follow these rules strictly:
|
||||
|
||||
#### Format Rules
|
||||
- Questions are PROPOSALS, not interview questions
|
||||
- Use "your partner" - never gendered pronouns
|
||||
- No time-specific language (tonight, now, today)
|
||||
- Keep concise: 5-15 words ideal
|
||||
#### 3.1 Core Language Rules
|
||||
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
|
||||
- Questions are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
- No time-specific words - NO "tonight", "now", "today", "right now"
|
||||
- Keep concise: 5-12 words ideal
|
||||
- Modern language for 25-40 year olds
|
||||
- Avoid cheesy/cliche phrasing
|
||||
- Avoid cheesy/cliche phrasing (candlelit dinner, rose petals, bubble bath, Netflix and chill)
|
||||
|
||||
#### Partner Text Rules
|
||||
- **NULL** for symmetric activities (both do the same thing)
|
||||
- **Filled** for asymmetric activities (different roles)
|
||||
#### 3.2 Tone Rules (based on category's is_explicit flag)
|
||||
|
||||
**For EXPLICIT categories (is_explicit = true):**
|
||||
- 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"
|
||||
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
|
||||
- NEVER sanitize "cum" to "come" - they have different meanings
|
||||
|
||||
**For NON-EXPLICIT categories (is_explicit = false):**
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
- If intensity 3+ activities are needed, use suggestive but not explicit language
|
||||
|
||||
#### 3.3 Partner Text Rules
|
||||
|
||||
- **NULL** for symmetric activities (both partners do the same thing together)
|
||||
- **Filled** for asymmetric activities (one does something to/for the other)
|
||||
- `text` = what the INITIATOR does
|
||||
- `partner_text` = what the RECEIVER experiences
|
||||
|
||||
**Partner Text Quality:**
|
||||
- Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
|
||||
- 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)
|
||||
- BAD: "Receive oral from your partner" (clinical)
|
||||
- GOOD: "Let your partner pleasure you with their mouth" (enticing)
|
||||
|
||||
#### 3.4 Critical: Inverse Questions
|
||||
|
||||
#### Critical: Inverse Questions
|
||||
For EVERY asymmetric question, create its inverse:
|
||||
|
||||
```
|
||||
@@ -74,24 +107,77 @@ Inverse (MUST CREATE):
|
||||
|
||||
This ensures both partners are asked about both roles.
|
||||
|
||||
#### Intensity Guidelines
|
||||
- **1**: Non-sexual bonding (cooking, gaming, quality time)
|
||||
- **2**: Romantic, flirty (kisses, cuddles, sweet messages)
|
||||
- **3**: Light sexual (oral, mutual masturbation, basic toys)
|
||||
- **4**: Explicit sexual (sex, light bondage, anal play)
|
||||
- **5**: Intense/BDSM (impact play, power exchange, edge play)
|
||||
#### 3.5 Intensity Guidelines
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
| 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 |
|
||||
|
||||
#### 3.6 Targeting Rules
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
- Options: `['male+male', 'female+male', 'female+female']` or subset
|
||||
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis (blowjob, handjob)
|
||||
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina (cunnilingus)
|
||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone) -> null
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: `['female']` in M+F couples
|
||||
- "Deep throat your partner" -> partner has penis -> `['female']` in M+F couples
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
- When in doubt, use null
|
||||
|
||||
**Anatomical Consistency:**
|
||||
- NEVER mix male-specific and female-specific acts as alternatives
|
||||
- BAD: "Finger or give your partner a handjob" (mixed anatomy)
|
||||
- GOOD: Pick one activity per question
|
||||
|
||||
#### 3.7 Required Props
|
||||
|
||||
Identify physical items/accessories needed:
|
||||
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
|
||||
- Props are NOT body parts or acts
|
||||
- Use lowercase, short, singular names
|
||||
- Reuse existing prop names from catalog when possible
|
||||
- Set to null if no props are required
|
||||
|
||||
### Step 4: Review & Validate
|
||||
|
||||
Before outputting, verify:
|
||||
- [ ] All asymmetric questions have inverses
|
||||
- [ ] No gendered pronouns
|
||||
- [ ] No time-specific language
|
||||
- [ ] Questions are proposals, not questions
|
||||
- [ ] Intensity levels are accurate
|
||||
- [ ] No cheesy language
|
||||
Before outputting, verify each question against this checklist:
|
||||
|
||||
**Language & Format:**
|
||||
- [ ] Uses "your partner" (no gendered pronouns)
|
||||
- [ ] Is a proposal, not an interview question
|
||||
- [ ] No wishy-washy language
|
||||
- [ ] No time-specific words
|
||||
- [ ] 5-12 words (concise)
|
||||
- [ ] No cheesy/cliche language
|
||||
- [ ] Varied sentence structures
|
||||
|
||||
**Structure:**
|
||||
- [ ] All asymmetric questions have inverses (these are NOT duplicates - they're required pairs)
|
||||
- [ ] Partner text is appealing (not clinical)
|
||||
- [ ] Partner text frames responses as "allowing" not forced
|
||||
- [ ] No actual duplicates (same `text` appearing twice)
|
||||
|
||||
**Accuracy:**
|
||||
- [ ] Intensity level matches the activity
|
||||
- [ ] Couple targeting is correct (or null for universal)
|
||||
- [ ] Initiator targeting is correct for asymmetric questions
|
||||
- [ ] Required props are identified
|
||||
- [ ] Considers same-sex couples
|
||||
|
||||
**Consistency:**
|
||||
- [ ] No mixed anatomy in alternatives
|
||||
- [ ] Tone matches explicit/non-explicit category
|
||||
- [ ] Fits progression within category
|
||||
|
||||
### Step 5: Output
|
||||
@@ -110,11 +196,18 @@ VALUES (
|
||||
RETURNING id;
|
||||
|
||||
-- Then insert questions (use returned pack_id)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity) VALUES
|
||||
('[pack_id]', 'Question text', 'Partner text or NULL', intensity),
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props) VALUES
|
||||
('[pack_id]', 'Question text', 'Partner text or NULL', intensity, NULL, NULL, NULL),
|
||||
('[pack_id]', 'Blowjob question', 'Partner text', 3, ARRAY['male+male', 'female+male'], ARRAY['female'], NULL),
|
||||
('[pack_id]', 'Toy question', 'Partner text', 4, NULL, NULL, ARRAY['remote vibrator']),
|
||||
-- ... all questions
|
||||
```
|
||||
|
||||
**Field notes:**
|
||||
- `allowed_couple_genders`: NULL for all couples, or array like `ARRAY['male+male', 'female+male']`
|
||||
- `target_user_genders`: NULL for any initiator, or array like `ARRAY['female']`
|
||||
- `required_props`: NULL if no props needed, or array like `ARRAY['blindfold', 'ice cubes']`
|
||||
|
||||
Also provide a summary table:
|
||||
|
||||
| Metric | Value |
|
||||
@@ -127,6 +220,9 @@ Also provide a summary table:
|
||||
| Intensity 5 | X |
|
||||
| Symmetric (null partner_text) | X |
|
||||
| Asymmetric (with inverses) | X pairs |
|
||||
| With couple targeting | X |
|
||||
| With initiator targeting | X |
|
||||
| With required props | X |
|
||||
|
||||
## Example Usage
|
||||
|
||||
@@ -146,3 +242,4 @@ Also provide a summary table:
|
||||
- DON'T assume heterosexual couples
|
||||
- DON'T skip the inverse question requirement
|
||||
- DON'T use interview-style questions ("Would you like to...")
|
||||
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
|
||||
argument-hint: <pack-name-or-id> [--explicit] [--limit=100]
|
||||
description: Review and fix questions for text, targeting, intensity, props, and duplicates
|
||||
---
|
||||
|
||||
# Question Review Command
|
||||
|
||||
Review and validate questions: $ARGUMENTS
|
||||
|
||||
## Task Overview
|
||||
|
||||
Analyze questions for issues and suggest fixes across 5 dimensions:
|
||||
1. **Text Phrasing** - Improve wording, remove wishy-washy language
|
||||
2. **Audience Targeting** - Fix couple/initiator gender targeting
|
||||
3. **Intensity Levels** - Correct misassigned intensity ratings
|
||||
4. **Required Props** - Identify missing physical props/accessories
|
||||
5. **Deletions** - Flag duplicates, redundant, or broken questions
|
||||
|
||||
## Step 1: Parse Arguments
|
||||
|
||||
Extract from $ARGUMENTS:
|
||||
- **Pack identifier**: Pack name (partial match) OR pack ID (UUID)
|
||||
- **--explicit**: Flag indicating this is an explicit/adult pack (default: false if not specified)
|
||||
- **--limit**: Max questions to analyze (default: 100)
|
||||
|
||||
## Step 2: Fetch Questions
|
||||
|
||||
Query the database:
|
||||
|
||||
```sql
|
||||
-- If pack name provided:
|
||||
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props
|
||||
FROM questions q
|
||||
JOIN question_packs p ON q.pack_id = p.id
|
||||
WHERE p.name ILIKE '%<pack_name>%'
|
||||
ORDER BY q.created_at
|
||||
LIMIT <limit>;
|
||||
|
||||
-- If pack ID provided:
|
||||
SELECT id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props
|
||||
FROM questions
|
||||
WHERE pack_id = '<pack_id>'
|
||||
ORDER BY created_at
|
||||
LIMIT <limit>;
|
||||
```
|
||||
|
||||
Also fetch existing props for consistency:
|
||||
```sql
|
||||
SELECT DISTINCT unnest(required_props) as prop
|
||||
FROM questions
|
||||
WHERE required_props IS NOT NULL
|
||||
ORDER BY prop;
|
||||
```
|
||||
|
||||
## Step 3: Determine Content Type
|
||||
|
||||
Check if the pack is explicit:
|
||||
```sql
|
||||
SELECT p.name, c.is_explicit
|
||||
FROM question_packs p
|
||||
JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = '<pack_id>' OR p.name ILIKE '%<pack_name>%'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
Use this to set the content type rules below.
|
||||
|
||||
## Step 4: Analyze Questions
|
||||
|
||||
For each question, evaluate against these criteria:
|
||||
|
||||
### 4.1 Text Analysis
|
||||
|
||||
**Language Rules:**
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
|
||||
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
- No time-specific words - NO "tonight", "now", "today", "right now"
|
||||
- Keep concise - ideal length is 5-12 words
|
||||
|
||||
**Tone Rules (based on explicit flag):**
|
||||
|
||||
For EXPLICIT packs:
|
||||
- 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)
|
||||
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
|
||||
- NEVER sanitize "cum" to "come" - they have different meanings
|
||||
|
||||
For NON-EXPLICIT packs:
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
- Flag explicit content for removal
|
||||
|
||||
**Partner Text Rules:**
|
||||
- NULL for symmetric activities (both partners do the same thing together)
|
||||
- FILLED for asymmetric activities (one does something to/for the other)
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER experiences
|
||||
- Make partner_text APPEALING - frame receiver's experience positively
|
||||
- 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)
|
||||
|
||||
### 4.2 Intensity Analysis
|
||||
|
||||
Check if current intensity matches the activity:
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
| 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 |
|
||||
|
||||
### 4.3 Targeting Analysis
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) 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)
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
|
||||
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
|
||||
### 4.4 Props Analysis
|
||||
|
||||
**Rules:**
|
||||
- Props are physical items/accessories, not body parts or acts
|
||||
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
|
||||
- Reuse existing prop names from the catalog when possible
|
||||
- If no props are required, set to null
|
||||
|
||||
### 4.5 Deletion Analysis
|
||||
|
||||
**Deletion Categories:**
|
||||
- `duplicate`: Same core action as another question
|
||||
- `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 too broken to repair without inventing content
|
||||
- `off-topic`: Not relevant to a couples intimacy app
|
||||
|
||||
**Rules:**
|
||||
- Prefer edits over deletions when possible
|
||||
- For duplicates: delete the weaker/less specific version
|
||||
- Do NOT delete for minor wording issues
|
||||
|
||||
**CRITICAL: Inverse Questions are NOT Duplicates**
|
||||
|
||||
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
|
||||
|
||||
```
|
||||
Question A:
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
|
||||
Question B (INVERSE - NOT A DUPLICATE):
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
```
|
||||
|
||||
These are intentionally paired so both partners get asked about both roles. When reviewing:
|
||||
- If you see two questions where one's `text` matches the other's `partner_text`, they are a valid inverse pair
|
||||
- Do NOT flag inverse pairs as duplicates or redundant
|
||||
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`)
|
||||
|
||||
## Step 5: Output Results
|
||||
|
||||
Present findings in a structured format:
|
||||
|
||||
### Summary
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Text fixes | X |
|
||||
| Intensity fixes | X |
|
||||
| Targeting fixes | X |
|
||||
| Props fixes | X |
|
||||
| Deletions suggested | X |
|
||||
| No changes needed | X |
|
||||
|
||||
### Suggested Changes
|
||||
|
||||
For each question needing changes:
|
||||
|
||||
```
|
||||
**Question ID:** <id>
|
||||
**Current Text:** <text>
|
||||
**Current Partner Text:** <partner_text or null>
|
||||
**Current Intensity:** <intensity>
|
||||
|
||||
Issues Found:
|
||||
- [ ] Text: <description of issue>
|
||||
- [ ] Intensity: <current> -> <suggested> (<reason>)
|
||||
- [ ] Targeting: <description>
|
||||
- [ ] Props: <current> -> <suggested>
|
||||
- [ ] DELETE: <category> - <reason>
|
||||
|
||||
Suggested Fix:
|
||||
- Text: "<new text>"
|
||||
- Partner Text: "<new partner_text>" or null
|
||||
- Intensity: <new intensity>
|
||||
- Couple Targets: <new allowed_couple_genders or null>
|
||||
- Initiator Targets: <new target_user_genders or null>
|
||||
- Required Props: <new required_props or null>
|
||||
```
|
||||
|
||||
### SQL to Apply Fixes
|
||||
|
||||
After user approval, provide UPDATE statements:
|
||||
|
||||
```sql
|
||||
-- Text/Partner Text/Intensity fixes
|
||||
UPDATE questions SET
|
||||
text = '<new_text>',
|
||||
partner_text = '<new_partner_text>',
|
||||
intensity = <new_intensity>
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Targeting fixes
|
||||
UPDATE questions SET
|
||||
allowed_couple_genders = '<new_targets>',
|
||||
target_user_genders = '<new_initiator>'
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Props fixes
|
||||
UPDATE questions SET
|
||||
required_props = ARRAY[<new_props>]
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Deletions
|
||||
DELETE FROM questions WHERE id IN ('<id1>', '<id2>', ...);
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/review-questions "Foreplay Favorites"
|
||||
/review-questions "Kink Discovery" --explicit
|
||||
/review-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask for clarification if pack name matches multiple packs
|
||||
- DO group similar issues together for batch fixes
|
||||
- DO prioritize safety issues (delete unsafe content first)
|
||||
- DO preserve the core intent of questions when suggesting text fixes
|
||||
- DON'T change questions that are already well-formed
|
||||
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
|
||||
- DON'T create new prop names when existing ones match
|
||||
+143
-12
@@ -39,6 +39,9 @@ Each question has:
|
||||
- `text` - What Partner A sees
|
||||
- `partner_text` - What Partner B sees (can be NULL)
|
||||
- `intensity` - 1-5 scale
|
||||
- `allowed_couple_genders` - Which couple types see this (NULL = all)
|
||||
- `target_user_genders` - Which gender initiates (NULL = any)
|
||||
- `required_props` - Physical items needed (NULL = none)
|
||||
|
||||
### When to use partner_text
|
||||
|
||||
@@ -75,6 +78,69 @@ This ensures:
|
||||
- Partner B gets asked if they want to give AND receive
|
||||
- All four combinations can be discovered through matching
|
||||
|
||||
**IMPORTANT: Inverse pairs are NOT duplicates.** When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates.
|
||||
|
||||
---
|
||||
|
||||
## Targeting Rules
|
||||
|
||||
### Couple Targeting (allowed_couple_genders)
|
||||
|
||||
Controls which couple types see the question. Options: `male+male`, `female+male`, `female+female`
|
||||
|
||||
**DEFAULT: NULL** (all couples) unless explicit anatomical requirement.
|
||||
|
||||
| Activity Type | Targeting | Reason |
|
||||
|--------------|-----------|--------|
|
||||
| Gender-neutral | NULL | Anyone can do it |
|
||||
| Requires penis | `['male+male', 'female+male']` | Exclude F+F |
|
||||
| Requires vagina | `['female+male', 'female+female']` | Exclude M+M |
|
||||
| Sex toys | NULL | Toys work for everyone |
|
||||
|
||||
Examples:
|
||||
- "Give your partner a massage" → NULL
|
||||
- "Give your partner a blowjob" → `['male+male', 'female+male']`
|
||||
- "Go down on your partner" → NULL (oral is possible for all)
|
||||
- "Use a vibrator on your partner" → NULL
|
||||
|
||||
### Initiator Targeting (target_user_genders)
|
||||
|
||||
For asymmetric questions, controls who does the action in `text`.
|
||||
|
||||
**DEFAULT: NULL** (anyone can initiate).
|
||||
|
||||
Only set when the `text` field requires specific anatomy:
|
||||
- "Swallow your partner's cum" → partner has penis → `['female']` in M+F
|
||||
- "Deep throat your partner" → partner has penis → `['female']` in M+F
|
||||
- "Let your partner cum inside you" → receiver has vagina → `['female']`
|
||||
|
||||
When in doubt, use NULL.
|
||||
|
||||
### Anatomical Consistency
|
||||
|
||||
**NEVER mix male-specific and female-specific acts in the same question.**
|
||||
|
||||
- BAD: "Finger or give your partner a handjob"
|
||||
- GOOD: Create separate questions for each
|
||||
|
||||
---
|
||||
|
||||
## Required Props
|
||||
|
||||
Identify physical items/accessories needed for the activity.
|
||||
|
||||
**Rules:**
|
||||
- Props are physical items, NOT body parts or acts
|
||||
- Use lowercase, short, singular names
|
||||
- Reuse existing prop names when possible
|
||||
- Set to NULL if no props needed
|
||||
|
||||
**Common props:**
|
||||
- blindfold, handcuffs, rope, massage oil
|
||||
- remote vibrator, butt plug, dildo, cock ring
|
||||
- ice cubes, feather, candle (for wax play)
|
||||
- mirror, camera/phone (for recording)
|
||||
|
||||
---
|
||||
|
||||
## Intensity Levels
|
||||
@@ -164,6 +230,7 @@ Most categories should have 3-5 packs that progress in intensity:
|
||||
- Use time-specific language
|
||||
- Be preachy or educational in tone
|
||||
- Include judgment about activities
|
||||
- Treat inverse pairs as duplicates (one's `text` = other's `partner_text` is INTENTIONAL)
|
||||
|
||||
### Avoid Cliches:
|
||||
- Candlelit dinner (unless specific twist)
|
||||
@@ -218,17 +285,20 @@ When generating packs, output in this format:
|
||||
-- Description: [Pack description]
|
||||
-- Intensity range: [X-Y]
|
||||
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity) VALUES
|
||||
('[pack_id]', 'Question text here', 'Partner text or NULL', intensity),
|
||||
('[pack_id]', 'Question text here', NULL, intensity),
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props) VALUES
|
||||
('[pack_id]', 'Question text here', 'Partner text or NULL', intensity, NULL, NULL, NULL),
|
||||
('[pack_id]', 'Blowjob question', 'Partner text', 3, ARRAY['male+male', 'female+male'], ARRAY['female'], NULL),
|
||||
('[pack_id]', 'Toy question', 'Partner text', 4, NULL, NULL, ARRAY['remote vibrator']),
|
||||
-- ... etc
|
||||
```
|
||||
|
||||
Or as a table:
|
||||
|
||||
| Text | Partner Text | Intensity |
|
||||
|------|--------------|-----------|
|
||||
| Question here | Partner perspective or null | 4 |
|
||||
| Text | Partner Text | Intensity | Couple Targets | Initiator | Props |
|
||||
|------|--------------|-----------|----------------|-----------|-------|
|
||||
| Question here | Partner perspective or null | 4 | NULL | NULL | NULL |
|
||||
| Blowjob | Partner text | 3 | M+M, F+M | female | NULL |
|
||||
| Toy play | Partner text | 4 | NULL | NULL | remote vibrator |
|
||||
|
||||
---
|
||||
|
||||
@@ -239,6 +309,9 @@ Or as a table:
|
||||
text: "Cook the same recipe together over video call"
|
||||
partner_text: NULL
|
||||
intensity: 1
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
```
|
||||
|
||||
### Romantic (Intensity 2)
|
||||
@@ -246,32 +319,67 @@ intensity: 1
|
||||
text: "Send a tipsy voice note telling them you miss them"
|
||||
partner_text: NULL
|
||||
intensity: 2
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
```
|
||||
|
||||
### Playful (Intensity 3)
|
||||
### Playful (Intensity 3) - With Props
|
||||
```
|
||||
text: "Blindfold your partner and tease them with different sensations"
|
||||
partner_text: "Be blindfolded while your partner teases you"
|
||||
intensity: 3
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: ["blindfold"]
|
||||
```
|
||||
AND its inverse:
|
||||
```
|
||||
text: "Be blindfolded while your partner teases you"
|
||||
partner_text: "Blindfold your partner and tease them with different sensations"
|
||||
intensity: 3
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: ["blindfold"]
|
||||
```
|
||||
|
||||
### Steamy (Intensity 4)
|
||||
### Steamy (Intensity 4) - With Props and Targeting
|
||||
```
|
||||
text: "Control your partner's remote vibrator while out at dinner"
|
||||
partner_text: "Wear a remote vibrator at dinner while your partner controls it"
|
||||
intensity: 4
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: ["remote vibrator"]
|
||||
```
|
||||
AND its inverse:
|
||||
```
|
||||
text: "Wear a remote vibrator at dinner while your partner controls it"
|
||||
partner_text: "Control your partner's remote vibrator while out at dinner"
|
||||
intensity: 4
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: ["remote vibrator"]
|
||||
```
|
||||
|
||||
### With Couple Targeting (Intensity 3)
|
||||
```
|
||||
text: "Give your partner a blowjob in a risky location"
|
||||
partner_text: "Receive a blowjob from your partner in a risky location"
|
||||
intensity: 3
|
||||
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
```
|
||||
|
||||
### With Initiator Targeting (Intensity 4)
|
||||
```
|
||||
text: "Swallow your partner's cum"
|
||||
partner_text: "Have your partner swallow your cum"
|
||||
intensity: 4
|
||||
allowed_couple_genders: ["male+male", "female+male"] -- requires penis
|
||||
target_user_genders: ["female"] -- in M+F, female receives
|
||||
required_props: NULL
|
||||
```
|
||||
|
||||
### Intense (Intensity 5)
|
||||
@@ -279,25 +387,48 @@ intensity: 4
|
||||
text: "Edge your partner repeatedly, denying release until you decide"
|
||||
partner_text: "Be edged and denied until your partner allows you to finish"
|
||||
intensity: 5
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
```
|
||||
AND its inverse:
|
||||
```
|
||||
text: "Be edged and denied until your partner allows you to finish"
|
||||
partner_text: "Edge your partner repeatedly, denying release until you decide"
|
||||
intensity: 5
|
||||
allowed_couple_genders: NULL
|
||||
target_user_genders: NULL
|
||||
required_props: NULL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Submitting
|
||||
|
||||
- [ ] All asymmetric questions have their inverse created
|
||||
- [ ] No gendered pronouns (him/her/he/she)
|
||||
- [ ] No time-specific language (tonight, now, today)
|
||||
**Language & Format:**
|
||||
- [ ] Uses "your partner" (no gendered pronouns like him/her/he/she)
|
||||
- [ ] Questions are proposals, not interview questions
|
||||
- [ ] Intensity levels are accurate
|
||||
- [ ] No wishy-washy language ("Would you...", "Have you ever...")
|
||||
- [ ] No time-specific language (tonight, now, today)
|
||||
- [ ] 5-12 words per question (concise)
|
||||
- [ ] No cheesy or cliche language
|
||||
- [ ] Mix of sentence structures
|
||||
- [ ] Appropriate for 25-40 year old couples
|
||||
|
||||
**Structure:**
|
||||
- [ ] All asymmetric questions have their inverse created (these are NOT duplicates - they're required pairs)
|
||||
- [ ] Partner text is appealing (not clinical)
|
||||
- [ ] Partner text frames responses as "allowing" not forced
|
||||
- [ ] No actual duplicates (same `text` appearing twice)
|
||||
|
||||
**Accuracy:**
|
||||
- [ ] Intensity levels match the activities
|
||||
- [ ] Couple targeting is correct (NULL unless anatomy-specific)
|
||||
- [ ] Initiator targeting is correct for asymmetric questions
|
||||
- [ ] Required props are identified where needed
|
||||
- [ ] Consider same-sex couples
|
||||
|
||||
**Consistency:**
|
||||
- [ ] No mixed anatomy in alternatives
|
||||
- [ ] Tone matches explicit/non-explicit category
|
||||
- [ ] Pack fits its position in the progression
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
|
||||
@@ -36,7 +36,7 @@ ${toneInstruction}
|
||||
</content_type>
|
||||
|
||||
<deletion_categories>
|
||||
- duplicate: same core action as another question (near-duplicate)
|
||||
- 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
|
||||
- unsafe: coercion, minors, illegal acts, or consent violations
|
||||
@@ -50,6 +50,7 @@ ${toneInstruction}
|
||||
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.
|
||||
5. IMPORTANT: Asymmetric versions of the same activity are NOT duplicates. If one question has text="Do X to your partner" and another has text="Let your partner do X to you", these are DIFFERENT questions because they have different initiators. Only flag as duplicate if both questions have the SAME initiator doing the SAME action.
|
||||
</rules>
|
||||
|
||||
<questions_to_analyze>
|
||||
|
||||
@@ -22,10 +22,27 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, GripVertical } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -49,6 +66,108 @@ interface CategoryFormData {
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sortable Card Component
|
||||
// =============================================================================
|
||||
|
||||
interface SortableCategoryCardProps {
|
||||
category: Category;
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
onToggleVisibility: (category: Category) => void;
|
||||
}
|
||||
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: category.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 'auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="group flex flex-col hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity touch-none"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<div className="rounded-lg bg-primary/10 p-2.5">
|
||||
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{category.is_public ? 'Public' : 'Hidden'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={category.is_public}
|
||||
onCheckedChange={() => onToggleVisibility(category)}
|
||||
className="scale-75 origin-right"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 h-10">
|
||||
{category.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
@@ -202,39 +321,6 @@ export function CategoriesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (category: Category, direction: 'up' | 'down') => {
|
||||
const currentIndex = categories.findIndex(c => c.id === category.id);
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= categories.length) return;
|
||||
|
||||
const targetCategory = categories[targetIndex];
|
||||
|
||||
try {
|
||||
const currentOrder = category.sort_order ?? currentIndex;
|
||||
const targetOrder = targetCategory.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update category order');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const newCategories = [...categories];
|
||||
newCategories[currentIndex] = { ...targetCategory, sort_order: currentOrder };
|
||||
newCategories[targetIndex] = { ...category, sort_order: targetOrder };
|
||||
setCategories(newCategories);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder categories');
|
||||
console.error(error);
|
||||
fetchCategories();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (category: Category) => {
|
||||
try {
|
||||
// Optimistic update
|
||||
@@ -256,6 +342,52 @@ export function CategoriesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Drag and Drop
|
||||
// =============================================================================
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = categories.findIndex(c => c.id === active.id);
|
||||
const newIndex = categories.findIndex(c => c.id === over.id);
|
||||
|
||||
// Optimistic UI update
|
||||
const newCategories = arrayMove(categories, oldIndex, newIndex);
|
||||
setCategories(newCategories);
|
||||
|
||||
// Persist new order to database
|
||||
try {
|
||||
const updates = newCategories.map((category, index) =>
|
||||
auditedSupabase.update('categories', category.id, { sort_order: index })
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
const hasError = results.some(r => r.error);
|
||||
|
||||
if (hasError) {
|
||||
throw new Error('Failed to update some categories');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save new order');
|
||||
console.error(error);
|
||||
fetchCategories(); // Revert on error
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
@@ -403,86 +535,28 @@ export function CategoriesPage() {
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category, index) => (
|
||||
<Card key={category.id} className="group flex flex-col hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="rounded-lg bg-primary/10 p-2.5">
|
||||
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{category.is_public ? 'Public' : 'Hidden'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={category.is_public}
|
||||
onCheckedChange={() => handleToggleVisibility(category)}
|
||||
className="scale-75 origin-right"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 h-10">
|
||||
{category.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex flex-col gap-0.5 mr-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-full"
|
||||
onClick={() => handleMove(category, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-full"
|
||||
onClick={() => handleMove(category, 'down')}
|
||||
disabled={index === categories.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => form.openEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={categories.map(c => c.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category) => (
|
||||
<SortableCategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
onEdit={form.openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Question {
|
||||
intensity: number;
|
||||
pack_id: string;
|
||||
created_at: string | null;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface QuestionFormData {
|
||||
@@ -25,15 +26,21 @@ export interface QuestionFormData {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all questions for a pack
|
||||
* Fetch all questions for a pack (excludes soft-deleted by default)
|
||||
*/
|
||||
export async function fetchQuestionsForPack(packId: string): Promise<Question[]> {
|
||||
const { data, error } = await supabase
|
||||
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
|
||||
let query = supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (!includeDeleted) {
|
||||
query = query.is('deleted_at', null);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -53,13 +60,14 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch question count for a pack
|
||||
* Fetch question count for a pack (excludes soft-deleted)
|
||||
*/
|
||||
export async function fetchQuestionCount(packId: string): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('pack_id', packId);
|
||||
.eq('pack_id', packId)
|
||||
.is('deleted_at', null);
|
||||
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
@@ -105,22 +113,36 @@ export async function updateQuestion(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a question
|
||||
* Soft delete a question (sets deleted_at timestamp)
|
||||
*/
|
||||
export async function deleteQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('questions', id);
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
deleted_at: new Date().toISOString(),
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple questions
|
||||
* Soft delete multiple questions
|
||||
*/
|
||||
export async function deleteQuestions(ids: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
ids.map(id => auditedSupabase.delete('questions', id))
|
||||
ids.map(id => auditedSupabase.update('questions', id, {
|
||||
deleted_at: new Date().toISOString(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted question
|
||||
*/
|
||||
export async function restoreQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
deleted_at: null,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create questions (for AI generation)
|
||||
*/
|
||||
|
||||
+27
-47
@@ -17,86 +17,66 @@ async function run() {
|
||||
console.error(`[Bridge] Connecting to ${url.href}`);
|
||||
console.error(`[Bridge] Using API Key (length: ${apiKey.length})`);
|
||||
|
||||
// 1. Diagnostic Fetch
|
||||
try {
|
||||
const response = await fetch(url.href, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Accept': 'text/event-stream'
|
||||
}
|
||||
});
|
||||
|
||||
console.error(`[Bridge] Diagnostic fetch status: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (response.status === 401) {
|
||||
console.error('[Bridge] Critical: Server returned 401 Unauthorized.');
|
||||
console.error('[Bridge] Please verify SAUCI_MCP_API_KEY environment variable on the server matches the key in Claude Desktop config.');
|
||||
const text = await response.text();
|
||||
console.error(`[Bridge] Server response: ${text}`);
|
||||
process.exit(1);
|
||||
} else if (!response.ok) {
|
||||
console.error(`[Bridge] Diagnostic fetch failed with status ${response.status}`);
|
||||
const text = await response.text();
|
||||
console.error(`[Bridge] Server response: ${text}`);
|
||||
} else {
|
||||
console.error('[Bridge] Diagnostic fetch successful (connection accepted). Proceeding with StreamableHTTP.');
|
||||
// Cancel body to close connection
|
||||
await response.body?.cancel();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Bridge] Diagnostic fetch failed with network error:', error);
|
||||
}
|
||||
|
||||
const local = new StdioServerTransport();
|
||||
|
||||
const remote = new StreamableHTTPClientTransport(url, {
|
||||
requestInit: {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge messages
|
||||
// Bridge messages from Claude Desktop to remote server
|
||||
local.onmessage = async (message) => {
|
||||
console.error(`[Bridge] -> Remote: ${JSON.stringify(message).slice(0, 100)}...`);
|
||||
try {
|
||||
await remote.send(message);
|
||||
} catch (err) {
|
||||
console.error('Failed to forward message to remote:', err);
|
||||
console.error('[Bridge] Failed to forward message to remote:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Bridge messages from remote server to Claude Desktop
|
||||
remote.onmessage = async (message) => {
|
||||
console.error(`[Bridge] <- Remote: ${JSON.stringify(message).slice(0, 100)}...`);
|
||||
try {
|
||||
await local.send(message);
|
||||
} catch (err) {
|
||||
console.error('Failed to forward message to local:', err);
|
||||
console.error('[Bridge] Failed to forward message to local:', err);
|
||||
}
|
||||
};
|
||||
|
||||
remote.onerror = (err) => {
|
||||
console.error('Remote transport error:', err);
|
||||
// Don't exit process immediately on error, allow reconnection logic if handled by transport,
|
||||
// but StreamableHTTPClientTransport usually handles its own reconnection for SSE.
|
||||
// If it's a fatal error, maybe we should exit?
|
||||
// Let's log it.
|
||||
console.error('[Bridge] Remote transport error:', err);
|
||||
};
|
||||
|
||||
local.onerror = (err) => {
|
||||
console.error('Local transport error:', err);
|
||||
console.error('[Bridge] Local transport error:', err);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
// Start connections
|
||||
await remote.start();
|
||||
local.onclose = () => {
|
||||
console.error('[Bridge] Local transport closed');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
remote.onclose = () => {
|
||||
console.error('[Bridge] Remote transport closed');
|
||||
};
|
||||
|
||||
// Start both transports
|
||||
console.error('[Bridge] Starting remote transport...');
|
||||
await remote.start();
|
||||
console.error('[Bridge] Remote transport started');
|
||||
|
||||
console.error('[Bridge] Starting local transport...');
|
||||
await local.start();
|
||||
console.error('[Bridge] Local transport started - ready for messages');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Bridge error:', err);
|
||||
console.error('[Bridge] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -149,6 +149,11 @@ export default function MatchesScreen() {
|
||||
}, [matches.length]);
|
||||
|
||||
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
||||
// Skip rendering if question was deleted
|
||||
if (!item.question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
|
||||
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@sauci/supabase",
|
||||
"version": "1.0.0",
|
||||
"private": true
|
||||
}
|
||||
Generated
+65
-1070
File diff suppressed because it is too large.
Load diff
@@ -42,6 +42,7 @@ export interface Question {
|
||||
target_user_genders?: string[] | null;
|
||||
required_props?: string[] | null;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
// User's answer type
|
||||
|
||||
Reference in new issue
Block a user