fixed age to be a single number instead of range, fixed language for AI model to include thematic analysis, and added review/revert/save function to AI persona modification mechanism

This commit is contained in:
michael 2025-09-08 16:10:03 -05:00
parent 1b977ec517
commit 8288cb9f5e
16 changed files with 226 additions and 65 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -165,6 +165,7 @@ async def modify_persona_with_ai(persona_id):
- llm_model: Model to use (defaults to 'gemini-2.5-pro')
- reasoning_effort: For GPT-5 (minimal, low, medium, high)
- verbosity: For GPT-5 (low, medium, high)
- preview_only: If true, returns modified data without saving to database (defaults to false)
"""
try:
# Get request data
@ -179,8 +180,10 @@ async def modify_persona_with_ai(persona_id):
llm_model = request_data.get('llm_model', 'gemini-2.5-pro')
reasoning_effort = request_data.get('reasoning_effort', 'medium')
verbosity = request_data.get('verbosity', 'medium')
preview_only = request_data.get('preview_only', False)
print(f"🤖 Backend: Modifying persona {persona_id} with {llm_model}")
mode_text = "previewing" if preview_only else "modifying"
print(f"🤖 Backend: {mode_text.title()} persona {persona_id} with {llm_model}")
print(f"📝 Modification prompt: {modification_prompt[:100]}...")
# Call the modification service
@ -189,13 +192,16 @@ async def modify_persona_with_ai(persona_id):
modification_prompt=modification_prompt,
llm_model=llm_model,
reasoning_effort=reasoning_effort,
verbosity=verbosity
verbosity=verbosity,
preview_only=preview_only
)
success_message = "Persona preview generated successfully" if preview_only else "Persona modified successfully"
return jsonify({
"success": True,
"message": "Persona modified successfully",
"persona": make_serializable(modified_persona_data)
"message": success_message,
"persona": make_serializable(modified_persona_data),
"preview_only": preview_only
}), 200
except PersonaModificationError as e:

View file

@ -178,6 +178,20 @@ async def generate_basic_personas(
raise PersonaGenerationError(
f"Persona {i+1} is missing required fields: {', '.join(missing_fields)}"
)
# Validate that age is a single number, not a range
age_value = persona.get("age", "")
if isinstance(age_value, str) and "-" in age_value:
raise PersonaGenerationError(
f"Persona {i+1} has an invalid age range '{age_value}'. Age must be a single specific number (e.g., '35', not '35-42')"
)
# Validate that age is numeric
age_str = str(age_value).strip()
if not age_str.isdigit():
raise PersonaGenerationError(
f"Persona {i+1} has an invalid age '{age_value}'. Age must be a numeric value (e.g., '35')"
)
return personas_array
@ -271,6 +285,20 @@ async def generate_persona(
if missing_fields:
raise PersonaGenerationError(f"Generated persona is missing required fields: {', '.join(missing_fields)}")
# Validate that age is a single number, not a range
age_value = persona_data.get("age", "")
if isinstance(age_value, str) and "-" in age_value:
raise PersonaGenerationError(
f"Generated persona has an invalid age range '{age_value}'. Age must be a single specific number (e.g., '35', not '35-42')"
)
# Validate that age is numeric
age_str = str(age_value).strip()
if not age_str.isdigit():
raise PersonaGenerationError(
f"Generated persona has an invalid age '{age_value}'. Age must be a numeric value (e.g., '35')"
)
# Generate ID if missing
if "id" not in persona_data:
persona_data["id"] = f"generated-{uuid.uuid4()}"

View file

@ -137,7 +137,8 @@ class PersonaModificationService:
llm_model: str = 'gemini-2.5-pro',
reasoning_effort: str = 'medium',
verbosity: str = 'medium',
max_retries: int = 3
max_retries: int = 3,
preview_only: bool = False
) -> Dict[str, Any]:
"""
Modify a persona using AI based on natural language instructions.
@ -149,6 +150,7 @@ class PersonaModificationService:
reasoning_effort: Reasoning effort for GPT-5 (minimal, low, medium, high)
verbosity: Response verbosity for GPT-5 (low, medium, high)
max_retries: Maximum number of retries for invalid responses
preview_only: If True, returns modified data without saving to database
Returns:
Dictionary containing the modified persona data
@ -211,13 +213,16 @@ class PersonaModificationService:
sanitized_persona, modified_persona_data
)
# Update the persona in the database
success = await Persona.update(persona_id, modified_persona_data)
if not success:
raise PersonaModificationError("Failed to update persona in database")
# Update the persona in the database (only if not preview mode)
if not preview_only:
success = await Persona.update(persona_id, modified_persona_data)
if not success:
raise PersonaModificationError("Failed to update persona in database")
logger.info(f"Successfully modified persona {persona_id}")
else:
logger.info(f"Generated preview for persona {persona_id} (not saved to database)")
# Return the modified persona data
logger.info(f"Successfully modified persona {persona_id}")
return modified_persona_data
except LLMServiceError as e:

View file

@ -51,6 +51,8 @@ EXAMPLE_JSON_START
]
EXAMPLE_JSON_END
CRITICAL AGE REQUIREMENT: The "age" field MUST contain a single, specific number (e.g., "35", "42") representing the persona's exact age. DO NOT use age ranges (e.g., "35-42", "30-35"). These are individual personas and each person has one specific age, not a range.
IMPORTANT:
- Return EXACTLY {count} personas in a JSON array format
- Do not include any comments (like "// Second persona") in the JSON

View file

@ -35,7 +35,7 @@ EXAMPLE_JSON_START
{
"id": "generated-[unique-id]",
"name": "[Full Name]",
"age": "[Age Range]",
"age": "[Specific Age]",
"gender": "[Gender]",
"occupation": "[Job Title]",
"education": "[Education Level]",
@ -106,4 +106,6 @@ EXAMPLE_JSON_START
}
EXAMPLE_JSON_END
CRITICAL AGE REQUIREMENT: The "age" field MUST contain a single, specific number (e.g., "35", "42") as a string, representing the persona's exact age. DO NOT use age ranges (e.g., "35-42", "30-35"). These are individual personas and each person has one specific age, not a range.
IMPORTANT: Return ONLY the JSON object with no additional text, explanations, or formatting.

4
dist/index.html vendored
View file

@ -7,8 +7,8 @@
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<meta property="og:image" content="/og-image.png" />
<script type="module" crossorigin src="/semblance/assets/index-CLQA4rqQ.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-8o0iGAjY.css">
<script type="module" crossorigin src="/semblance/assets/index-DeCgXQlM.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-D95t4u2x.css">
</head>
<body>

View file

@ -1556,7 +1556,7 @@ true;
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for generating responses and discussion guides
Choose which AI model to use for generating responses, discussion guides, and thematic analysis
</FormDescription>
<FormMessage />
</FormItem>

View file

@ -50,14 +50,14 @@ interface PersonaModificationModalProps {
persona: Persona;
isOpen: boolean;
onClose: () => void;
onPersonaModified: (modifiedPersona: Persona) => void;
onPersonaPreview: (modifiedPersona: Persona) => void;
}
export default function PersonaModificationModal({
persona,
isOpen,
onClose,
onPersonaModified
onPersonaPreview
}: PersonaModificationModalProps) {
const [isProcessing, setIsProcessing] = useState(false);
@ -81,8 +81,8 @@ export default function PersonaModificationModal({
setIsProcessing(true);
try {
toastService.info("Modifying persona with AI...", {
description: `Using ${values.llm_model} to process your modification request`
toastService.info("Generating persona preview...", {
description: `Using ${values.llm_model} to create a preview of your modifications`
});
// Use the persona's MongoDB _id or fallback to id
@ -92,26 +92,27 @@ export default function PersonaModificationModal({
modification_prompt: values.modificationPrompt,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium'
verbosity: values.verbosity || 'medium',
preview_only: true
});
if (response.data && response.data.persona) {
toastService.success("Persona modified successfully!", {
description: `${persona.name} has been updated with AI modifications`
toastService.success("Preview generated successfully!", {
description: `Ready to review proposed changes to ${persona.name}`
});
onPersonaModified(response.data.persona);
onPersonaPreview(response.data.persona);
handleClose();
} else {
throw new Error("Invalid response from server");
}
} catch (error: any) {
console.error("Error modifying persona:", error);
console.error("Error generating persona preview:", error);
if (error.response) {
const errorMessage = error.response.data?.error || "Server error occurred";
toastService.error("Failed to modify persona", {
toastService.error("Failed to generate preview", {
description: errorMessage
});
} else if (error.request) {
@ -119,7 +120,7 @@ export default function PersonaModificationModal({
description: "Unable to connect to the server"
});
} else {
toastService.error("Modification failed", {
toastService.error("Preview generation failed", {
description: error.message || "An unexpected error occurred"
});
}
@ -283,12 +284,12 @@ export default function PersonaModificationModal({
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
Generating Preview...
</>
) : (
<>
<Wand2 className="h-4 w-4" />
Process Persona Modification
Generate Preview
</>
)}
</Button>

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import Navigation from '@/components/Navigation';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@ -30,12 +30,17 @@ import { usePersonaDetails } from '@/hooks/usePersonaDetails';
export default function PersonaProfile() {
const {
currentPersona,
displayPersona,
isEditing,
isFromReview,
isLoading,
isReviewMode,
setIsEditing,
handleGoBack,
handleSaveEdit
handleSaveEdit,
enterReviewMode,
exitReviewMode,
saveReviewedPersona
} = usePersonaDetails();
const { navigationState } = useNavigation();
@ -43,6 +48,25 @@ export default function PersonaProfile() {
const [isExporting, setIsExporting] = useState(false);
const [isModificationModalOpen, setIsModificationModalOpen] = useState(false);
// Navigation blocking during review mode
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isReviewMode) {
e.preventDefault();
e.returnValue = "You have unsaved changes. Your modifications will be lost if you leave.";
return e.returnValue;
}
};
if (isReviewMode) {
window.addEventListener('beforeunload', handleBeforeUnload);
}
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isReviewMode]);
// Fetch focus group name if coming from focus group session
useEffect(() => {
if (navigationState.focusGroupId && navigationState.previousRoute?.startsWith('/focus-groups/')) {
@ -196,43 +220,85 @@ export default function PersonaProfile() {
</div>
)}
{/* Review Mode Banner */}
{isReviewMode && (
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-400 rounded-r-lg">
<div className="flex items-center">
<div className="ml-3">
<p className="text-sm text-amber-800">
📝 <strong>Reviewing proposed changes to {displayPersona?.name}</strong> - Save or cancel to continue
</p>
</div>
</div>
</div>
)}
<div className="flex items-center mb-6 relative">
<Button
variant="ghost"
onClick={handleGoBack}
onClick={() => {
if (isReviewMode) {
if (confirm("You have unsaved changes. Your modifications will be lost if you leave. Do you want to continue?")) {
exitReviewMode();
handleGoBack();
}
} else {
handleGoBack();
}
}}
className="absolute left-0 top-0 flex items-center"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-sf text-3xl font-bold text-slate-900 ml-16">Persona Profile</h1>
<div className="absolute right-0 top-0 flex items-center gap-3">
<Button
variant="outline"
onClick={handleExportProfile}
disabled={isExporting}
className="hover-transition"
>
<Download className="h-4 w-4 mr-2" />
{isExporting ? 'Generating...' : 'Download Profile'}
</Button>
<Button
variant="outline"
onClick={() => setIsModificationModalOpen(true)}
className="hover-transition"
>
<Bot className="h-4 w-4 mr-2" />
Modify with AI
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit className="h-4 w-4 mr-2" />
Edit Persona
</Button>
{isReviewMode ? (
<>
<Button
variant="outline"
onClick={exitReviewMode}
className="hover-transition"
>
Cancel Revision
</Button>
<Button
onClick={saveReviewedPersona}
className="bg-green-600 hover:bg-green-700 text-white"
>
Save Revised Persona
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleExportProfile}
disabled={isExporting}
className="hover-transition"
>
<Download className="h-4 w-4 mr-2" />
{isExporting ? 'Generating...' : 'Download Profile'}
</Button>
<Button
variant="outline"
onClick={() => setIsModificationModalOpen(true)}
className="hover-transition"
>
<Bot className="h-4 w-4 mr-2" />
Modify with AI
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit className="h-4 w-4 mr-2" />
Edit Persona
</Button>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-10">
<div className={`grid grid-cols-1 lg:grid-cols-3 gap-6 mt-10 ${isReviewMode ? 'border-2 border-amber-400 rounded-lg p-4 bg-amber-50/30' : ''}`}>
<div className="lg:col-span-1">
<PersonaSidebar persona={currentPersona} />
<PersonaSidebar persona={displayPersona} />
</div>
<div className="lg:col-span-2">
@ -241,23 +307,23 @@ export default function PersonaProfile() {
<TabsTrigger value="cooper-profile">Cooper Profile</TabsTrigger>
<TabsTrigger value="personality">Personality</TabsTrigger>
<TabsTrigger value="scenarios">Scenarios</TabsTrigger>
<TabsTrigger value="generation-prompts">Generation Prompts</TabsTrigger>
<TabsTrigger value="generation-prompts">Persona Inputs</TabsTrigger>
</TabsList>
<TabsContent value="cooper-profile" className="mt-6">
<PersonaCooperProfile persona={currentPersona} />
<PersonaCooperProfile persona={displayPersona} />
</TabsContent>
<TabsContent value="personality" className="mt-6">
<PersonaPersonality persona={currentPersona} />
<PersonaPersonality persona={displayPersona} />
</TabsContent>
<TabsContent value="scenarios" className="mt-6">
<PersonaScenarios persona={currentPersona} />
<PersonaScenarios persona={displayPersona} />
</TabsContent>
<TabsContent value="generation-prompts" className="mt-6">
<PersonaGenerationPrompts persona={currentPersona} />
<PersonaGenerationPrompts persona={displayPersona} />
</TabsContent>
</Tabs>
</div>
@ -271,10 +337,9 @@ export default function PersonaProfile() {
persona={currentPersona}
isOpen={isModificationModalOpen}
onClose={() => setIsModificationModalOpen(false)}
onPersonaModified={(modifiedPersona) => {
// Update the current persona with the modified data
// This will refresh the persona detail view
window.location.reload();
onPersonaPreview={(modifiedPersona) => {
// Enter review mode with the modified persona data
enterReviewMode(modifiedPersona);
}}
/>
)}

View file

@ -552,8 +552,10 @@ export function usePersonaDetails() {
const navigate = useNavigate();
const { navigationState, clearNavigationState } = useNavigation();
const [currentPersona, setCurrentPersona] = useState<Persona | undefined>(undefined);
const [reviewPersona, setReviewPersona] = useState<Persona | undefined>(undefined);
const [isFromReview, setIsFromReview] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isReviewMode, setIsReviewMode] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
@ -729,14 +731,63 @@ export function usePersonaDetails() {
// If we got here, the save was successful
return true;
};
const enterReviewMode = (modifiedPersona: Persona) => {
setReviewPersona(modifiedPersona);
setIsReviewMode(true);
};
const exitReviewMode = () => {
setReviewPersona(undefined);
setIsReviewMode(false);
};
const saveReviewedPersona = async () => {
if (!reviewPersona || !currentPersona) return false;
try {
// Use the persona's MongoDB _id or fallback to id
const personaId = currentPersona._id || currentPersona.id;
// Use the update API since we already have the modified data
const updateResponse = await personasApi.update(personaId, reviewPersona);
if (updateResponse) {
toast.success("Persona saved successfully!");
setCurrentPersona(reviewPersona);
exitReviewMode();
// Give time for state update to complete before reloading
setTimeout(() => {
window.location.reload();
}, 100);
return true;
} else {
toast.error("Failed to save persona changes");
return false;
}
} catch (error) {
console.error("Error saving reviewed persona:", error);
toast.error("Failed to save persona changes: " + (error.message || "Unknown error"));
return false;
}
};
// Get the persona to display (review persona if in review mode, otherwise current persona)
const displayPersona = isReviewMode ? reviewPersona : currentPersona;
return {
currentPersona,
displayPersona,
isEditing,
isFromReview,
isLoading,
isReviewMode,
reviewPersona,
setIsEditing,
handleGoBack,
handleSaveEdit
handleSaveEdit,
enterReviewMode,
exitReviewMode,
saveReviewedPersona
};
}

View file

@ -172,7 +172,8 @@ export const personasApi = {
modifyWithAI: (id: string, modificationData: any) => {
const personaId = typeof id === 'object' && id !== null ? (id as any)._id || id : id;
console.log(`Modifying persona with AI, ID: ${personaId}`);
const mode = modificationData.preview_only ? 'Previewing' : 'Modifying';
console.log(`${mode} persona with AI, ID: ${personaId}`);
return api.post(`/personas/${personaId}/modify-with-ai`, modificationData);
},

View file

@ -2283,7 +2283,7 @@ const FocusGroupSession = () => {
<DialogHeader>
<DialogTitle>AI Model Settings</DialogTitle>
<DialogDescription>
Choose which AI model to use for generating responses and discussion guides in this focus group.
Choose which AI model to use for generating responses, discussion guides, and thematic analysis in this focus group.
</DialogDescription>
</DialogHeader>