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:
Vadym Samoilenko 2026-05-25 20:50:50 +01:00
parent 8109fe3768
commit f746dbee8e
2 changed files with 114 additions and 23 deletions

View file

@ -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}")

View file

@ -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