diff --git a/.DS_Store b/.DS_Store index f32eb597..ec5e5dcc 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/app/routes/__pycache__/personas.cpython-313.pyc b/backend/app/routes/__pycache__/personas.cpython-313.pyc index c7bd2b98..4e303915 100644 Binary files a/backend/app/routes/__pycache__/personas.cpython-313.pyc and b/backend/app/routes/__pycache__/personas.cpython-313.pyc differ diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index 4bbae4a7..8d70b67e 100644 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -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: diff --git a/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc b/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc index 3adfedcf..b377fea4 100644 Binary files a/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc and b/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/persona_modification_service.cpython-313.pyc b/backend/app/services/__pycache__/persona_modification_service.cpython-313.pyc index 03bad893..662000db 100644 Binary files a/backend/app/services/__pycache__/persona_modification_service.cpython-313.pyc and b/backend/app/services/__pycache__/persona_modification_service.cpython-313.pyc differ diff --git a/backend/app/services/ai_persona_service.py b/backend/app/services/ai_persona_service.py index 7211967f..aec3fb4b 100644 --- a/backend/app/services/ai_persona_service.py +++ b/backend/app/services/ai_persona_service.py @@ -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()}" diff --git a/backend/app/services/persona_modification_service.py b/backend/app/services/persona_modification_service.py index 1a25f7af..bad46854 100644 --- a/backend/app/services/persona_modification_service.py +++ b/backend/app/services/persona_modification_service.py @@ -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: diff --git a/backend/prompts/persona-basic-generation.md b/backend/prompts/persona-basic-generation.md index 3aabd1bd..0a30d543 100644 --- a/backend/prompts/persona-basic-generation.md +++ b/backend/prompts/persona-basic-generation.md @@ -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 diff --git a/backend/prompts/persona-detailed-generation.md b/backend/prompts/persona-detailed-generation.md index 6fc86608..184c38fc 100644 --- a/backend/prompts/persona-detailed-generation.md +++ b/backend/prompts/persona-detailed-generation.md @@ -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. \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index 081a98f6..e505d8ce 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/src/components/FocusGroupModerator.tsx b/src/components/FocusGroupModerator.tsx index 075a1047..b7ee3682 100644 --- a/src/components/FocusGroupModerator.tsx +++ b/src/components/FocusGroupModerator.tsx @@ -1556,7 +1556,7 @@ true; - 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 diff --git a/src/components/persona/PersonaModificationModal.tsx b/src/components/persona/PersonaModificationModal.tsx index a61ef578..37065721 100644 --- a/src/components/persona/PersonaModificationModal.tsx +++ b/src/components/persona/PersonaModificationModal.tsx @@ -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 ? ( <> - Processing... + Generating Preview... ) : ( <> - Process Persona Modification + Generate Preview )} diff --git a/src/components/persona/PersonaProfile.tsx b/src/components/persona/PersonaProfile.tsx index e195cc9f..32ecbae8 100644 --- a/src/components/persona/PersonaProfile.tsx +++ b/src/components/persona/PersonaProfile.tsx @@ -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() { )} + {/* Review Mode Banner */} + {isReviewMode && ( +
+
+
+

+ 📝 Reviewing proposed changes to {displayPersona?.name} - Save or cancel to continue +

+
+
+
+ )} +

Persona Profile

- - - + {isReviewMode ? ( + <> + + + + ) : ( + <> + + + + + )}
-
+
- +
@@ -241,23 +307,23 @@ export default function PersonaProfile() { Cooper Profile Personality Scenarios - Generation Prompts + Persona Inputs - + - + - + - +
@@ -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); }} /> )} diff --git a/src/hooks/usePersonaDetails.ts b/src/hooks/usePersonaDetails.ts index 3ff1e2c4..d672bc2c 100644 --- a/src/hooks/usePersonaDetails.ts +++ b/src/hooks/usePersonaDetails.ts @@ -552,8 +552,10 @@ export function usePersonaDetails() { const navigate = useNavigate(); const { navigationState, clearNavigationState } = useNavigation(); const [currentPersona, setCurrentPersona] = useState(undefined); + const [reviewPersona, setReviewPersona] = useState(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 }; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 410e4bc5..e01fb90f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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); }, diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 9a3eb64a..5b67b923 100644 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -2283,7 +2283,7 @@ const FocusGroupSession = () => { AI Model Settings - 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.