fix(ai): silent participants + call_out sent as message + contrarian fix
- _apply_silent_participant_override: new Python-level override that forces any participant who hasn't spoken yet into the next turn (fires before contrarian check, language-aware Russian/English call-out) - _execute_participant_respond: now sends call_out as a moderator message before generating the participant response (was silently skipped before, causing 0-action loops and incoherent conversation flow); uses .get() instead of [] to avoid KeyError when LLM omits optional fields - _apply_contrarian_override: language-aware call-out message (Russian if last moderator message contains Cyrillic) - conversation-decision-engine.md: explicit rules that call_out MUST name the participant and topic_context MUST give a specific angle; silent participants must be called before repeat speakers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8109fe3768
commit
f746dbee8e
2 changed files with 114 additions and 23 deletions
|
|
@ -397,11 +397,17 @@ 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)
|
||||
|
||||
# Python override 1: force silent participants in before contrarian logic
|
||||
decision = await self._apply_silent_participant_override(decision, all_messages)
|
||||
if decision.get('reasoning', '').startswith('Python override: silent'):
|
||||
self.logger.info(f"Silent override: {decision['reasoning']}")
|
||||
|
||||
# Python override 2: contrarian when consensus detected
|
||||
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']}")
|
||||
if decision.get('reasoning', '').startswith('Python override: consensus'):
|
||||
self.logger.info(f"Contrarian override: {decision['reasoning']}")
|
||||
|
||||
# Store reasoning in history for UI display and get the database ID
|
||||
reasoning_id = await self._store_reasoning(decision)
|
||||
|
|
@ -418,6 +424,70 @@ class AutonomousConversationController:
|
|||
self.logger.error(f"Unexpected error in decision making: {str(e)}")
|
||||
return None
|
||||
|
||||
async def _apply_silent_participant_override(
|
||||
self, decision: Dict[str, Any], all_messages: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Force a silent participant into the conversation.
|
||||
|
||||
Fires when any participant hasn't spoken at all AND the decision is not
|
||||
already targeting them. Skips if action is end_session or probe_trigger
|
||||
(those are more important).
|
||||
"""
|
||||
if decision.get('action') in ('end_session',):
|
||||
return decision
|
||||
|
||||
focus_group = await FocusGroup.find_by_id(self.focus_group_id)
|
||||
if not focus_group:
|
||||
return decision
|
||||
|
||||
participant_ids = focus_group.get('participants', [])
|
||||
if not participant_ids:
|
||||
return decision
|
||||
|
||||
# Participants who have never spoken
|
||||
spoke_ids = {m.get('senderId') for m in all_messages if m.get('senderId') != 'moderator'}
|
||||
silent_ids = [pid for pid in participant_ids if str(pid) not in {str(s) for s in spoke_ids}]
|
||||
|
||||
if not silent_ids:
|
||||
return decision # Everyone has spoken at least once
|
||||
|
||||
# Pick the first silent participant not spoken in the last 2 turns
|
||||
recent_speakers = {m.get('senderId') for m in all_messages[-4:]}
|
||||
candidates = [pid for pid in silent_ids if str(pid) not in {str(r) for r in recent_speakers}]
|
||||
target_id = str(candidates[0] if candidates else silent_ids[0])
|
||||
|
||||
persona = await Persona.find_by_id(target_id)
|
||||
if not persona:
|
||||
return decision
|
||||
|
||||
target_name = persona.get('name', 'participant')
|
||||
|
||||
# Detect conversation language from last moderator message
|
||||
last_mod = next(
|
||||
(m.get('text', '') for m in reversed(all_messages) if m.get('senderId') == 'moderator'),
|
||||
''
|
||||
)
|
||||
# Simple heuristic: Cyrillic chars → Russian call-out
|
||||
is_russian = any('Ѐ' <= c <= 'ӿ' for c in last_mod)
|
||||
if is_russian:
|
||||
call_msg = f"{target_name}, мы ещё не слышали вас. Что думаете об этом?"
|
||||
ctx = "Поделитесь своей точкой зрения на то, что обсуждалось. Будьте конкретны и честны в соответствии со своим характером."
|
||||
else:
|
||||
call_msg = f"{target_name}, we haven't heard from you yet — what's your take on this?"
|
||||
ctx = "Share your perspective on what has been discussed. Be specific and authentic to your personality."
|
||||
|
||||
self.logger.info(f"🔕 Silent override: {target_name} has not spoken — forcing participation")
|
||||
return {
|
||||
'action': 'participant_respond',
|
||||
'reasoning': f'Python override: silent participant — {target_name} has not spoken yet',
|
||||
'details': {
|
||||
'participant_id': target_id,
|
||||
'call_out': call_msg,
|
||||
'topic_context': ctx,
|
||||
},
|
||||
'discussion_guide_position_id': decision.get('discussion_guide_position_id', '1'),
|
||||
}
|
||||
|
||||
async def _apply_contrarian_override(
|
||||
self, decision: Dict[str, Any], all_messages: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
|
|
@ -486,19 +556,31 @@ class AutonomousConversationController:
|
|||
self.logger.info(
|
||||
f"🔄 Contrarian override: high agreement ({agreement_ratio:.0%}) → calling {contrarian_name}"
|
||||
)
|
||||
|
||||
# Language-aware call-out
|
||||
all_msgs_local = all_messages # already in scope
|
||||
last_mod_text = next(
|
||||
(m.get('text', '') for m in reversed(all_msgs_local) if m.get('senderId') == 'moderator'),
|
||||
''
|
||||
)
|
||||
is_ru = any('Ѐ' <= c <= 'ӿ' for c in last_mod_text)
|
||||
if is_ru:
|
||||
call_msg = f"{contrarian_name}, все вроде соглашаются — а вы что думаете? Есть сомнения или возражения?"
|
||||
ctx_msg = "Группа пришла к единому мнению. Выразите свои конкретные сомнения, риски или минусы с вашей точки зрения."
|
||||
else:
|
||||
call_msg = f"{contrarian_name}, the group seems to agree — do you have any reservations or a different take?"
|
||||
ctx_msg = "The group has been agreeing. Express your specific reservations, concerns, or the downsides you see from your perspective."
|
||||
|
||||
return {
|
||||
'action': 'participant_respond',
|
||||
'reasoning': (
|
||||
f'Python override: consensus detected (agreement ratio {agreement_ratio:.0%}), '
|
||||
f'Python override: consensus ({agreement_ratio:.0%} agreement ratio) — '
|
||||
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.'
|
||||
),
|
||||
'call_out': call_msg,
|
||||
'topic_context': ctx_msg,
|
||||
},
|
||||
'discussion_guide_position_id': decision.get('discussion_guide_position_id', '1'),
|
||||
}
|
||||
|
|
@ -660,18 +742,22 @@ class AutonomousConversationController:
|
|||
async def _execute_participant_respond(self, details: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Execute participant respond action."""
|
||||
try:
|
||||
self.is_generating = True # Mark as generating
|
||||
|
||||
participant_id = details["participant_id"]
|
||||
topic_context = details["topic_context"]
|
||||
|
||||
|
||||
# Generate participant response
|
||||
self.is_generating = True
|
||||
|
||||
participant_id = details.get("participant_id") or details.get("id")
|
||||
topic_context = details.get("topic_context") or details.get("topic") or ""
|
||||
call_out = details.get("call_out") or details.get("callOut") or ""
|
||||
|
||||
if not participant_id:
|
||||
return {"error": "participant_id missing from decision details"}
|
||||
|
||||
# Send moderator call-out message so the conversation flow is visible
|
||||
if call_out:
|
||||
await self._add_moderator_message(call_out, "question")
|
||||
|
||||
result = await self._generate_participant_response(participant_id, topic_context)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error in participant respond: {str(e)}"
|
||||
self.logger.error(f"❌ {error_msg}")
|
||||
|
|
|
|||
|
|
@ -141,17 +141,22 @@ EXAMPLE_JSON_START
|
|||
}
|
||||
EXAMPLE_JSON_END
|
||||
|
||||
**participant_respond**: When a specific participant should respond
|
||||
**participant_respond**: When a specific participant should respond.
|
||||
CRITICAL RULES for this action:
|
||||
- `call_out` MUST be a natural moderator phrase that calls on the person by name (e.g. "Sophie, we haven't heard from you — what do you think?"). This is sent as a moderator message BEFORE the participant responds.
|
||||
- `topic_context` MUST give the participant a clear instruction on what angle/position to take (e.g. "Express scepticism about the price point based on your budget constraints"). Be specific, not generic.
|
||||
- ⚠️ SILENT participants (marked "has not spoken yet" in analytics) MUST be called on before anyone who has already spoken 2+ times.
|
||||
|
||||
EXAMPLE_JSON_START
|
||||
{
|
||||
"action": "participant_respond",
|
||||
"reasoning": "Why this participant should speak",
|
||||
"details": {
|
||||
"participant_id": "selected_participant_id",
|
||||
"call_out": "How to naturally call on them",
|
||||
"topic_context": "Current topic they're responding to"
|
||||
"call_out": "Sophie, мы ещё не слышали вас — что вы думаете об этом?",
|
||||
"topic_context": "Share your honest perspective. If you have doubts or a different angle, express them clearly."
|
||||
},
|
||||
"discussion_guide_position_id": "7" // specify the exact question they're answering (use numerical IDs)
|
||||
"discussion_guide_position_id": "7"
|
||||
}
|
||||
EXAMPLE_JSON_END
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue