From f746dbee8e42f72b70b55ffcf60ba650fa7337b7 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 25 May 2026 20:50:50 +0100 Subject: [PATCH] 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 --- .../autonomous_conversation_controller.py | 124 +++++++++++++++--- .../prompts/conversation-decision-engine.md | 13 +- 2 files changed, 114 insertions(+), 23 deletions(-) diff --git a/backend/app/services/autonomous_conversation_controller.py b/backend/app/services/autonomous_conversation_controller.py index c04e79f6..e8d4e3c1 100755 --- a/backend/app/services/autonomous_conversation_controller.py +++ b/backend/app/services/autonomous_conversation_controller.py @@ -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}") diff --git a/backend/prompts/conversation-decision-engine.md b/backend/prompts/conversation-decision-engine.md index 6a43a579..e1e68ae2 100755 --- a/backend/prompts/conversation-decision-engine.md +++ b/backend/prompts/conversation-decision-engine.md @@ -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