feat(personas): structural improvements for realistic focus group dialogue
- Per-persona message history: each persona now sees their own 8 previous responses, preventing repetition and enabling position evolution - OCEAN archetype labels in decision engine context: instead of raw numbers, the decision LLM now sees "agreeableness: 72/100 [HIGH] — consensus-seeker" - P2P interaction context: when participants interact directly, each one now knows who they are responding to and what that person last said - Python-level contrarian override: when agreement ratio in recent messages exceeds 6% and a contrarian persona (low agreeableness or high neuroticism) hasn't spoken recently, Python overrides moderator/probe action to call them Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e23e52f77d
commit
72e8dadb20
4 changed files with 158 additions and 16 deletions
|
|
@ -396,7 +396,13 @@ class AutonomousConversationController:
|
|||
)
|
||||
|
||||
self.logger.info(f"LLM Decision: {decision['action']} - {decision['reasoning']}")
|
||||
|
||||
|
||||
# Python-level contrarian override: if consensus detected and LLM didn't call a skeptic
|
||||
all_messages = await FocusGroup.get_messages(self.focus_group_id)
|
||||
decision = await self._apply_contrarian_override(decision, all_messages)
|
||||
if decision.get('reasoning', '').startswith('Python override'):
|
||||
self.logger.info(f"Decision overridden to: {decision['action']} - {decision['reasoning']}")
|
||||
|
||||
# Store reasoning in history for UI display and get the database ID
|
||||
reasoning_id = await self._store_reasoning(decision)
|
||||
|
||||
|
|
@ -412,6 +418,91 @@ class AutonomousConversationController:
|
|||
self.logger.error(f"Unexpected error in decision making: {str(e)}")
|
||||
return None
|
||||
|
||||
async def _apply_contrarian_override(
|
||||
self, decision: Dict[str, Any], all_messages: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Override moderator/probe action with a contrarian participant when consensus is detected.
|
||||
|
||||
Only fires when: action is moderator_speak or probe_trigger AND the last 8 participant
|
||||
messages are heavily agreement-weighted AND a contrarian persona (low agreeableness or
|
||||
high neuroticism) hasn't spoken in the last 6 turns.
|
||||
"""
|
||||
if decision.get('action') not in ('moderator_speak', 'probe_trigger'):
|
||||
return decision
|
||||
|
||||
# Measure recent agreement level in the last 8 participant messages
|
||||
participant_msgs = [m for m in all_messages if m.get('senderId') != 'moderator'][-8:]
|
||||
if len(participant_msgs) < 3:
|
||||
return decision # Not enough data
|
||||
|
||||
agreement_words = {'agree', 'yes', 'exactly', 'definitely', 'absolutely', 'same', 'too', 'also',
|
||||
'согласен', 'согласна', 'конечно', 'точно', 'именно', 'да', 'тоже'}
|
||||
disagreement_words = {'disagree', 'but', 'however', 'different', 'wrong', 'not',
|
||||
'не', 'нет', 'однако', 'зато', 'хотя', 'против', 'сомневаюсь'}
|
||||
agree_count = 0
|
||||
total_words = 0
|
||||
for m in participant_msgs:
|
||||
words = m.get('text', '').lower().split()
|
||||
total_words += len(words)
|
||||
agree_count += sum(1 for w in words if w.strip(".,!?;:\"'") in agreement_words)
|
||||
if total_words == 0:
|
||||
return decision
|
||||
agreement_ratio = agree_count / total_words
|
||||
if agreement_ratio < 0.06: # ~6% threshold — below this, no override
|
||||
return decision
|
||||
|
||||
# Find recent speakers (last 6 messages)
|
||||
recent_speaker_ids = {m.get('senderId') for m in all_messages[-6:]}
|
||||
|
||||
# Load participants and find a contrarian not spoken recently
|
||||
focus_group = await FocusGroup.find_by_id(self.focus_group_id)
|
||||
if not focus_group:
|
||||
return decision
|
||||
participant_ids = focus_group.get('participants', [])
|
||||
contrarian = None
|
||||
for pid in participant_ids:
|
||||
if pid in recent_speaker_ids:
|
||||
continue
|
||||
persona = await Persona.find_by_id(pid)
|
||||
if not persona:
|
||||
continue
|
||||
ocean = persona.get('oceanTraits', {})
|
||||
agreeableness = ocean.get('agreeableness', 50)
|
||||
neuroticism = ocean.get('neuroticism', 50)
|
||||
personality = str(persona.get('personality', '')).lower()
|
||||
is_contrarian = (
|
||||
agreeableness < 40
|
||||
or neuroticism > 65
|
||||
or any(w in personality for w in ('skeptic', 'sceptic', 'critical', 'скептик', 'критик'))
|
||||
)
|
||||
if is_contrarian:
|
||||
contrarian = (pid, persona.get('name', 'participant'))
|
||||
break
|
||||
|
||||
if not contrarian:
|
||||
return decision
|
||||
|
||||
contrarian_id, contrarian_name = contrarian
|
||||
self.logger.info(
|
||||
f"🔄 Contrarian override: high agreement ({agreement_ratio:.0%}) → calling {contrarian_name}"
|
||||
)
|
||||
return {
|
||||
'action': 'participant_respond',
|
||||
'reasoning': (
|
||||
f'Python override: consensus detected (agreement ratio {agreement_ratio:.0%}), '
|
||||
f'calling contrarian {contrarian_name}'
|
||||
),
|
||||
'details': {
|
||||
'participant_id': str(contrarian_id),
|
||||
'call_out': f"{contrarian_name}, we haven't heard from you — what's your take?",
|
||||
'topic_context': (
|
||||
'The group has been agreeing. Express your specific reservations, concerns, '
|
||||
'or the downsides you see from your perspective.'
|
||||
),
|
||||
},
|
||||
'discussion_guide_position_id': decision.get('discussion_guide_position_id', '1'),
|
||||
}
|
||||
|
||||
async def _store_reasoning(self, decision: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Store reasoning from AI decision for UI display.
|
||||
|
|
@ -594,24 +685,44 @@ class AutonomousConversationController:
|
|||
"""Execute participant interaction action."""
|
||||
try:
|
||||
self.is_generating = True # Mark as generating
|
||||
|
||||
|
||||
participant_ids = details["participant_ids"]
|
||||
moderator_prompt = details["moderator_prompt"]
|
||||
|
||||
|
||||
# Add moderator prompt for interaction
|
||||
await self._add_moderator_message(moderator_prompt, "question")
|
||||
|
||||
# Generate responses from both participants
|
||||
|
||||
# Fetch all messages once so we can find what each person last said
|
||||
all_messages = await FocusGroup.get_messages(self.focus_group_id)
|
||||
|
||||
results = []
|
||||
for participant_id in participant_ids:
|
||||
result = await self._generate_participant_response(participant_id, moderator_prompt)
|
||||
# Build context: include the other participant's most recent message
|
||||
other_ids = [pid for pid in participant_ids if pid != participant_id]
|
||||
p2p_context = moderator_prompt
|
||||
if other_ids and all_messages:
|
||||
other_msgs = [
|
||||
m for m in all_messages
|
||||
if m.get('senderId') in other_ids and m.get('type') != 'question'
|
||||
]
|
||||
if other_msgs:
|
||||
last_msg = other_msgs[-1]
|
||||
other_persona = await Persona.find_by_id(other_ids[0])
|
||||
other_name = other_persona.get('name', 'another participant') if other_persona else 'another participant'
|
||||
p2p_context = (
|
||||
f"{moderator_prompt}\n\n"
|
||||
f"[DIRECT RESPONSE: You are responding to {other_name} who just said: "
|
||||
f'"{last_msg.get("text", "").strip()}"]'
|
||||
)
|
||||
|
||||
result = await self._generate_participant_response(participant_id, p2p_context)
|
||||
results.append(result)
|
||||
|
||||
|
||||
return {
|
||||
"message": "Participant interaction executed",
|
||||
"participant_responses": results
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Error in participant interaction: {str(e)}"}
|
||||
finally:
|
||||
|
|
@ -693,10 +804,14 @@ class AutonomousConversationController:
|
|||
verbosity = focus_group.get('verbosity', 'medium')
|
||||
self.logger.info(f"🤖 Autonomous conversation using model: {llm_model or 'default (gpt-5.4)'} for focus group {self.focus_group_id}")
|
||||
|
||||
# Get recent messages
|
||||
# Get recent messages and this persona's own history
|
||||
messages = await FocusGroup.get_messages(self.focus_group_id)
|
||||
recent_messages = messages[-20:] if len(messages) > 20 else messages
|
||||
|
||||
persona_own_history = [
|
||||
m for m in messages
|
||||
if m.get('senderId') == participant_id and m.get('type') != 'question'
|
||||
][-8:] # Last 8 of their own messages for memory
|
||||
|
||||
# Generate response with timeout to prevent infinite hang
|
||||
try:
|
||||
response_text = await asyncio.wait_for(
|
||||
|
|
@ -708,7 +823,8 @@ class AutonomousConversationController:
|
|||
focus_group_id=self.focus_group_id,
|
||||
llm_model=llm_model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
verbosity=verbosity
|
||||
verbosity=verbosity,
|
||||
persona_own_history=persona_own_history,
|
||||
),
|
||||
timeout=self.response_timeout
|
||||
)
|
||||
|
|
|
|||
|
|
@ -444,12 +444,23 @@ class ConversationContextService:
|
|||
participants_text += f"- Personality: {participant['personality']['description']}\n"
|
||||
|
||||
if ocean_traits:
|
||||
participants_text += f"- OCEAN Traits: "
|
||||
_archetype_hints = {
|
||||
'agreeableness': {'HIGH': 'consensus-seeker, validates, harmonises', 'LOW': 'challenger, questions, disrupts consensus'},
|
||||
'neuroticism': {'HIGH': 'risk-focused, raises concerns, worst-case thinker', 'LOW': 'stabiliser, calm, grounded'},
|
||||
'conscientiousness':{'HIGH': 'detail skeptic, demands evidence, challenges vague claims'},
|
||||
'openness': {'HIGH': 'innovator, welcomes new angles', 'LOW': 'traditionalist, skeptical of novelty'},
|
||||
'extraversion': {'HIGH': 'conversation driver, speaks freely', 'LOW': 'deliberate contributor, speaks less but considered'},
|
||||
}
|
||||
traits = []
|
||||
for trait, value in ocean_traits.items():
|
||||
if isinstance(value, (int, float)):
|
||||
traits.append(f"{trait.capitalize()}: {value}/100")
|
||||
participants_text += ", ".join(traits) + "\n"
|
||||
level = 'LOW' if value < 35 else ('HIGH' if value > 65 else 'MODERATE')
|
||||
archetype = _archetype_hints.get(trait, {}).get(level, '')
|
||||
label = f"{trait.capitalize()}: {value}/100 [{level}]"
|
||||
if archetype:
|
||||
label += f" — {archetype}"
|
||||
traits.append(label)
|
||||
participants_text += f"- OCEAN Traits: {'; '.join(traits)}\n"
|
||||
|
||||
goals = participant.get('personality', {}).get('goals', [])
|
||||
if goals:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ async def generate_persona_response(
|
|||
focus_group_id: Optional[str] = None,
|
||||
llm_model: Optional[str] = None,
|
||||
reasoning_effort: Optional[str] = None,
|
||||
verbosity: Optional[str] = None
|
||||
verbosity: Optional[str] = None,
|
||||
persona_own_history: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a response from a persona in a focus group discussion.
|
||||
|
|
@ -92,6 +93,13 @@ async def generate_persona_response(
|
|||
persona_details = _format_persona_details(persona)
|
||||
behavioral_constraints_section = _generate_behavioral_instructions(persona)
|
||||
|
||||
# Format persona's own history for memory
|
||||
if persona_own_history:
|
||||
own_msgs = [f"- {m.get('text', '').strip()}" for m in persona_own_history if m.get('text', '').strip()]
|
||||
persona_history_text = "\n".join(own_msgs) if own_msgs else "This is your first response in this discussion."
|
||||
else:
|
||||
persona_history_text = "This is your first response in this discussion."
|
||||
|
||||
# If we have visual context, use contextual generation
|
||||
if has_visual_context and multimodal_context:
|
||||
print(f"🎨 Using contextual generation with visual context")
|
||||
|
|
@ -103,6 +111,7 @@ async def generate_persona_response(
|
|||
'behavioral_constraints_section': behavioral_constraints_section,
|
||||
'current_topic': current_topic,
|
||||
'previous_messages': multimodal_context['text_context'], # Use text fallback
|
||||
'persona_own_history': persona_history_text,
|
||||
'length_instructions': length_instructions,
|
||||
'is_creative_review': True, # Flag to indicate visual context available
|
||||
'creative_instructions': """
|
||||
|
|
@ -149,6 +158,7 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
|
|||
'behavioral_constraints_section': behavioral_constraints_section,
|
||||
'current_topic': current_topic,
|
||||
'previous_messages': formatted_messages,
|
||||
'persona_own_history': persona_history_text,
|
||||
'length_instructions': length_instructions
|
||||
})
|
||||
except PromptLoaderError as e:
|
||||
|
|
|
|||
|
|
@ -10,10 +10,15 @@ You are acting as a synthetic persona in a focus group discussion. Your goal is
|
|||
## DEMOGRAPHIC CONTEXT:
|
||||
Draw on your specific demographic background — race/ethnicity, cultural values, income and financial situation, family structure, living situation — to provide responses that reflect YOUR lived experience, not generic answers. For example: how products appear on your specific skin tone, how they align with your cultural values, whether the price makes sense given your financial situation, how your family situation affects your decision-making.
|
||||
|
||||
## YOUR OWN PREVIOUS RESPONSES IN THIS DISCUSSION:
|
||||
{persona_own_history}
|
||||
|
||||
**MEMORY RULE**: Do NOT repeat points you have already made above. Instead — evolve your position, add new dimensions, reference your earlier statements to build on them, or shift your view based on what you've heard. If this is your first response, establish your initial position clearly.
|
||||
|
||||
## CURRENT QUESTION OR TOPIC:
|
||||
{current_topic}
|
||||
|
||||
## PREVIOUS MESSAGES:
|
||||
## PREVIOUS MESSAGES (full group conversation):
|
||||
{previous_messages}
|
||||
|
||||
## RESPONSE LENGTH GUIDANCE:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue