added persona modification service - users can adjust individual personas with NLP via LLM

This commit is contained in:
michael 2025-08-26 12:45:15 -05:00
parent fbef4f42f6
commit 065c368aeb
14 changed files with 808 additions and 152 deletions

20
.env
View file

@ -1,13 +1,13 @@
# Production Environment Configuration
# Frontend URL (production server)
VITE_FRONTEND_BASE_URL=https://ai-sandbox.oliver.solutions/semblance
# Development Environment Configuration
# Frontend URL (local development)
VITE_FRONTEND_BASE_URL=http://localhost:5173
# Backend API URL (production server)
VITE_API_BASE_URL=https://ai-sandbox.oliver.solutions/semblance_back/api
# Backend API URL (local development - no base path)
VITE_API_BASE_URL=/api
# WebSocket path (production server)
VITE_WEBSOCKET_PATH=/semblance_back/socket.io/
# WebSocket path (local development - no base path)
VITE_WEBSOCKET_PATH=/socket.io/
# MSAL Authentication (production server)
VITE_MSAL_REDIRECT_URI=https://ai-sandbox.oliver.solutions/semblance
VITE_MSAL_POST_LOGOUT_REDIRECT_URI=https://ai-sandbox.oliver.solutions/semblance
# MSAL Authentication (local development)
VITE_MSAL_REDIRECT_URI=http://localhost:5173/
VITE_MSAL_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
venv/
*pycache*/

View file

@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.persona import Persona
from app.services.persona_export_service import PersonaExportService
from app.services.persona_modification_service import PersonaModificationService, PersonaModificationError
from bson import ObjectId
import datetime
@ -153,6 +154,57 @@ def create_multiple_personas():
"persona_ids": persona_ids
}), 201
@personas_bp.route('/<persona_id>/modify-with-ai', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
def modify_persona_with_ai(persona_id):
"""
Modify a persona using AI based on natural language instructions.
Request body should include:
- modification_prompt: Natural language description of desired changes
- 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)
"""
try:
# Get request data
request_data = request.get_json()
if not request_data:
return jsonify({"error": "No request data provided"}), 400
modification_prompt = request_data.get('modification_prompt')
if not modification_prompt:
return jsonify({"error": "modification_prompt is required"}), 400
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')
print(f"🤖 Backend: Modifying persona {persona_id} with {llm_model}")
print(f"📝 Modification prompt: {modification_prompt[:100]}...")
# Call the modification service
modified_persona_data = PersonaModificationService.modify_persona(
persona_id=persona_id,
modification_prompt=modification_prompt,
llm_model=llm_model,
reasoning_effort=reasoning_effort,
verbosity=verbosity
)
return jsonify({
"success": True,
"message": "Persona modified successfully",
"persona": make_serializable(modified_persona_data)
}), 200
except PersonaModificationError as e:
print(f"❌ Persona modification error: {e}")
return jsonify({"error": str(e)}), 400
except Exception as e:
print(f"❌ Unexpected error in persona modification: {e}")
return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
def export_persona_profile(persona_id):

View file

@ -0,0 +1,231 @@
"""
Persona Modification Service
This service handles AI-powered modification of existing personas using natural language instructions.
It integrates with the LLM service to process modification requests while maintaining data integrity
and internal consistency of persona attributes.
"""
import json
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from .llm_service import LLMService, LLMServiceError
from app.utils.prompt_loader import load_prompt, PromptLoaderError
from app.models.persona import Persona
from bson import ObjectId
logger = logging.getLogger(__name__)
class PersonaModificationError(Exception):
"""Exception raised for errors in the persona modification process."""
pass
class PersonaModificationService:
"""Service for modifying personas using AI."""
@staticmethod
def _sanitize_persona_for_json(persona_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Sanitize persona data to make it JSON serializable for the LLM prompt.
Args:
persona_data: The persona data dictionary that may contain non-serializable objects
Returns:
A sanitized dictionary that can be JSON serialized
"""
sanitized = {}
for key, value in persona_data.items():
if isinstance(value, ObjectId):
# Convert ObjectId to string
sanitized[key] = str(value)
elif isinstance(value, datetime):
# Convert datetime to ISO string
sanitized[key] = value.isoformat()
elif isinstance(value, dict):
# Recursively sanitize nested dictionaries
sanitized[key] = PersonaModificationService._sanitize_persona_for_json(value)
elif isinstance(value, list):
# Sanitize list items
sanitized_list = []
for item in value:
if isinstance(item, dict):
sanitized_list.append(PersonaModificationService._sanitize_persona_for_json(item))
elif isinstance(item, ObjectId):
sanitized_list.append(str(item))
elif isinstance(item, datetime):
sanitized_list.append(item.isoformat())
else:
sanitized_list.append(item)
sanitized[key] = sanitized_list
else:
# Keep other values as-is
sanitized[key] = value
return sanitized
@staticmethod
def _protect_readonly_fields(original_persona: Dict[str, Any], modified_persona: Dict[str, Any]) -> Dict[str, Any]:
"""
Protect readonly fields from being modified by the LLM.
Args:
original_persona: The original persona data
modified_persona: The LLM-modified persona data
Returns:
Modified persona with readonly fields restored from original
"""
# List of fields that should never be modified
protected_fields = ['id', '_id', 'created_at', 'created_by']
for field in protected_fields:
if field in original_persona:
modified_persona[field] = original_persona[field]
# Ensure updated_at is set to current time
modified_persona['updated_at'] = datetime.utcnow().isoformat()
return modified_persona
@staticmethod
def _validate_persona_structure(persona_data: Dict[str, Any]) -> bool:
"""
Validate that the modified persona contains all required fields.
Args:
persona_data: The persona data to validate
Returns:
True if valid, False otherwise
"""
required_fields = ['name', 'age', 'gender', 'occupation', 'location', 'personality']
for field in required_fields:
if field not in persona_data or persona_data[field] is None:
logger.error(f"Missing required field: {field}")
return False
# Validate numeric fields are within expected ranges
numeric_fields = {
'techSavviness': (0, 100),
'brandLoyalty': (0, 100),
'priceConsciousness': (0, 100),
'environmentalConcern': (0, 100)
}
for field, (min_val, max_val) in numeric_fields.items():
if field in persona_data:
try:
value = int(persona_data[field])
if not (min_val <= value <= max_val):
logger.error(f"Field {field} value {value} out of range [{min_val}, {max_val}]")
return False
except (ValueError, TypeError):
logger.error(f"Field {field} is not a valid number")
return False
return True
@staticmethod
def modify_persona(
persona_id: str,
modification_prompt: str,
llm_model: str = 'gemini-2.5-pro',
reasoning_effort: str = 'medium',
verbosity: str = 'medium',
max_retries: int = 3
) -> Dict[str, Any]:
"""
Modify a persona using AI based on natural language instructions.
Args:
persona_id: The ID of the persona to modify
modification_prompt: Natural language description of desired changes
llm_model: The LLM model to use for modification
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
Returns:
Dictionary containing the modified persona data
Raises:
PersonaModificationError: If modification fails or validation fails
"""
try:
# Fetch the original persona
original_persona = Persona.find_by_id(persona_id)
if not original_persona:
raise PersonaModificationError(f"Persona with ID {persona_id} not found")
# Convert to dict and sanitize for JSON serialization
original_persona_dict = dict(original_persona) if hasattr(original_persona, '_data') else original_persona
sanitized_persona = PersonaModificationService._sanitize_persona_for_json(original_persona_dict)
# Load the modification prompt template
try:
final_prompt = load_prompt('persona-modification', {
'original_persona_json': json.dumps(sanitized_persona, indent=2),
'modification_prompt': modification_prompt
})
except PromptLoaderError as e:
logger.error(f"Failed to load persona modification prompt: {e}")
raise PersonaModificationError(f"Failed to load modification prompt: {str(e)}")
# Attempt modification with retries
for attempt in range(max_retries):
try:
logger.info(f"Attempting persona modification (attempt {attempt + 1}/{max_retries})")
# Call LLM service
llm_response = LLMService.generate_content(
prompt=final_prompt,
temperature=0.3, # Lower temperature for consistent modifications
model_name=llm_model,
reasoning_effort=reasoning_effort if llm_model == 'gpt-5' else None,
verbosity=verbosity if llm_model == 'gpt-5' else None
)
# Parse JSON response
try:
modified_persona_data = json.loads(llm_response.strip())
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON response on attempt {attempt + 1}: {e}")
if attempt == max_retries - 1:
raise PersonaModificationError(f"LLM returned invalid JSON after {max_retries} attempts")
continue
# Validate the modified persona structure
if not PersonaModificationService._validate_persona_structure(modified_persona_data):
logger.warning(f"Invalid persona structure on attempt {attempt + 1}")
if attempt == max_retries - 1:
raise PersonaModificationError(f"LLM returned invalid persona structure after {max_retries} attempts")
continue
# Protect readonly fields
modified_persona_data = PersonaModificationService._protect_readonly_fields(
sanitized_persona, modified_persona_data
)
# Update the persona in the database
success = Persona.update(persona_id, modified_persona_data)
if not success:
raise PersonaModificationError("Failed to update persona in database")
# Return the modified persona data
logger.info(f"Successfully modified persona {persona_id}")
return modified_persona_data
except LLMServiceError as e:
logger.error(f"LLM service error on attempt {attempt + 1}: {e}")
if attempt == max_retries - 1:
raise PersonaModificationError(f"LLM service failed after {max_retries} attempts: {str(e)}")
continue
except Exception as e:
logger.error(f"Unexpected error during persona modification: {e}")
raise PersonaModificationError(f"Persona modification failed: {str(e)}")

View file

@ -0,0 +1,40 @@
You are an expert persona modification assistant. Your task is to modify an existing synthetic persona based on natural language instructions while maintaining data integrity, internal consistency, and realistic human characteristics.
CRITICAL REQUIREMENTS:
1. Return ONLY properly formatted JSON with no additional text, explanations, or markdown formatting
2. Preserve all original JSON structure and field names exactly as provided
3. Maintain internal consistency between personality traits, goals, frustrations, behaviors, and demographics
4. Ensure modifications are realistic and authentic for human personas
5. Protected fields (id, _id, created_at, created_by) must NEVER be modified
MODIFICATION GUIDELINES:
- Make only the changes requested in the modification prompt
- If demographic changes are requested, ensure they align with personality and behavioral traits
- If personality changes are requested, update related fields (OCEAN traits, goals, frustrations, scenarios) accordingly
- Maintain coherence between all persona elements - demographics, personality, behaviors, and life situations
- Keep the persona realistic and avoid stereotypes
- If income/economic status changes, adjust related fields like purchasing power, price consciousness, etc.
- If tech savviness changes, ensure device usage and communication preferences align
- If profession changes, update education, goals, frustrations, and relevant scenarios
FIELD INTEGRITY RULES:
- All numeric fields (techSavviness, brandLoyalty, etc.) must remain within 0-100 range
- Boolean fields (hasPurchasingPower, hasChildren) must remain true/false
- OCEAN trait scores must sum to realistic human personality patterns
- Required fields from the original persona must never be removed
- Maintain consistent formatting for dates, arrays, and nested objects
INTERNAL CONSISTENCY CHECKS:
- If age changes significantly, adjust life stage-appropriate goals, scenarios, and tech usage
- If location changes, consider cultural and economic implications
- If education level changes, ensure occupation and interests are appropriate
- If family status changes (hasChildren), update relevant scenarios and priorities
- If environmental concern changes, reflect in lifestyle choices and brand preferences
Original Persona Data:
{original_persona_json}
Modification Instructions:
{modification_prompt}
Return the complete modified persona JSON with all original fields preserved and only the requested changes applied. Ensure the persona remains internally consistent and realistic.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-QYazl09u.css vendored Normal file

File diff suppressed because one or more lines are too long

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-NobeZ-BW.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-C-lVT2hb.css">
<script type="module" crossorigin src="/semblance/assets/index-D1F9eUHr.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-QYazl09u.css">
</head>
<body>

View file

@ -0,0 +1,301 @@
import React, { useState } from 'react';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Loader2, Bot, Wand2 } from 'lucide-react';
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { personasApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { Persona } from '@/types/persona';
const modificationFormSchema = z.object({
modificationPrompt: z.string().min(10, {
message: "Modification prompt must be at least 10 characters long.",
}),
llm_model: z.string().min(1, {
message: "Please select an AI model.",
}),
reasoning_effort: z.string().optional(),
verbosity: z.string().optional(),
});
type ModificationFormData = z.infer<typeof modificationFormSchema>;
interface PersonaModificationModalProps {
persona: Persona;
isOpen: boolean;
onClose: () => void;
onPersonaModified: (modifiedPersona: Persona) => void;
}
export default function PersonaModificationModal({
persona,
isOpen,
onClose,
onPersonaModified
}: PersonaModificationModalProps) {
const [isProcessing, setIsProcessing] = useState(false);
const form = useForm<ModificationFormData>({
resolver: zodResolver(modificationFormSchema),
defaultValues: {
modificationPrompt: "",
llm_model: "gemini-2.5-pro",
reasoning_effort: "medium",
verbosity: "medium",
},
});
const handleClose = () => {
if (isProcessing) return; // Prevent closing while processing
form.reset();
onClose();
};
const onSubmit = async (values: ModificationFormData) => {
setIsProcessing(true);
try {
toastService.info("Modifying persona with AI...", {
description: `Using ${values.llm_model} to process your modification request`
});
// Use the persona's MongoDB _id or fallback to id
const personaId = persona._id || persona.id;
const response = await personasApi.modifyWithAI(personaId, {
modification_prompt: values.modificationPrompt,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium'
});
if (response.data && response.data.persona) {
toastService.success("Persona modified successfully!", {
description: `${persona.name} has been updated with AI modifications`
});
onPersonaModified(response.data.persona);
handleClose();
} else {
throw new Error("Invalid response from server");
}
} catch (error: any) {
console.error("Error modifying persona:", error);
if (error.response) {
const errorMessage = error.response.data?.error || "Server error occurred";
toastService.error("Failed to modify persona", {
description: errorMessage
});
} else if (error.request) {
toastService.error("Network error", {
description: "Unable to connect to the server"
});
} else {
toastService.error("Modification failed", {
description: error.message || "An unexpected error occurred"
});
}
} finally {
setIsProcessing(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<Bot className="h-5 w-5 text-primary" />
Modify Persona with Natural Language
</DialogTitle>
<DialogDescription>
Describe the changes you'd like to make to <strong>{persona.name}</strong>,
and AI will modify the persona data accordingly.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Modification Prompt */}
<FormField
control={form.control}
name="modificationPrompt"
render={({ field }) => (
<FormItem>
<FormLabel>Modification Instructions</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="E.g., 'Make this person more tech-savvy and interested in sustainable products' or 'Increase their income level and add marketing experience'"
className="min-h-[120px]"
disabled={isProcessing}
/>
</FormControl>
<FormDescription>
Describe what aspects of the persona you'd like to modify or enhance
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* AI Model Selection */}
<FormField
control={form.control}
name="llm_model"
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isProcessing}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-2.5-pro">Gemini 2.5 Pro</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
<SelectItem value="gpt-5">GPT-5</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for modifying the persona
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* GPT-5 specific parameters */}
{form.watch("llm_model") === "gpt-5" && (
<>
{/* Reasoning Effort Parameter */}
<FormField
control={form.control}
name="reasoning_effort"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Effort</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isProcessing}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select reasoning effort" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="minimal">Minimal - Fast responses</SelectItem>
<SelectItem value="low">Low - Quick thinking</SelectItem>
<SelectItem value="medium">Medium - Balanced (default)</SelectItem>
<SelectItem value="high">High - Deep reasoning</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Controls how much the AI thinks through the modification
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Verbosity Parameter */}
<FormField
control={form.control}
name="verbosity"
render={({ field }) => (
<FormItem>
<FormLabel>Response Detail</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isProcessing}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select verbosity level" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Concise</SelectItem>
<SelectItem value="medium">Balanced (default)</SelectItem>
<SelectItem value="high">Detailed</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Controls how detailed the AI's modifications will be
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isProcessing}
>
Cancel
</Button>
<Button
type="submit"
disabled={isProcessing}
className="flex items-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Wand2 className="h-4 w-4" />
Process Persona Modification
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -11,7 +11,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { ArrowLeft, Edit, Home, Users, User, Download } from 'lucide-react';
import { ArrowLeft, Edit, Home, Users, User, Download, Bot } from 'lucide-react';
import { useNavigation } from '@/contexts/NavigationContext';
import { focusGroupsApi, personasApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
@ -23,6 +23,7 @@ import { PersonaScenarios } from './PersonaScenarios';
import { PersonaNotFound } from './PersonaNotFound';
import { PersonaProfileSkeleton } from './PersonaProfileSkeleton';
import PersonaEditor from './PersonaEditor';
import PersonaModificationModal from './PersonaModificationModal';
import { usePersonaDetails } from '@/hooks/usePersonaDetails';
export default function PersonaProfile() {
@ -39,6 +40,7 @@ export default function PersonaProfile() {
const { navigationState } = useNavigation();
const [focusGroupName, setFocusGroupName] = useState<string>('');
const [isExporting, setIsExporting] = useState(false);
const [isModificationModalOpen, setIsModificationModalOpen] = useState(false);
// Fetch focus group name if coming from focus group session
useEffect(() => {
@ -212,6 +214,14 @@ export default function PersonaProfile() {
<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
@ -248,6 +258,20 @@ export default function PersonaProfile() {
</div>
</>
)}
{/* Persona Modification Modal */}
{currentPersona && (
<PersonaModificationModal
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();
}}
/>
)}
</main>
</div>
);

View file

@ -143,6 +143,12 @@ export const personasApi = {
}
return api.put(`/personas/${id}`, personaData);
},
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}`);
return api.post(`/personas/${personaId}/modify-with-ai`, modificationData);
},
delete: (id: string) => {
// Handle different ID formats