semblance/backend/app/services/conversation_decision_service.py
2025-12-19 19:26:16 +00:00

384 lines
No EOL
16 KiB
Python
Executable file

"""
Conversation Decision Service
Uses LLM to make intelligent decisions about conversation flow, participant selection, and moderation.
"""
from typing import Dict, Any, Optional, List
import json
from app.services.llm_service import LLMService, LLMServiceError
from app.services.conversation_context_service import ConversationContextService
from app.utils.prompt_loader import load_prompt, PromptLoaderError
class ConversationDecisionError(Exception):
"""Exception raised for errors in conversation decision making."""
pass
class ConversationDecisionService:
"""Service for making LLM-based conversation decisions."""
@staticmethod
async def decide_next_action(focus_group_id: str, temperature: float = 0.7, mode: str = "ai") -> Dict[str, Any]:
"""
Use LLM to decide the next action in the conversation.
Args:
focus_group_id: The focus group ID
temperature: LLM temperature for decision making
mode: The conversation mode - "ai" for autonomous mode, "manual" for manual mode
Returns:
Dictionary containing the decision and action details
Raises:
ConversationDecisionError: If there's an issue with decision making
"""
print(f"🎯 Decision request: {mode} mode for focus group {focus_group_id}")
try:
# Get full conversation context
context = await ConversationContextService.get_full_context(focus_group_id)
formatted_context = ConversationContextService.format_context_for_llm(context)
# Load the appropriate prompt based on mode
try:
if mode == "manual":
prompt_name = 'conversation-participant-selection'
else:
prompt_name = 'conversation-decision-engine'
prompt = load_prompt(prompt_name, formatted_context)
except PromptLoaderError as e:
print(f"❌ Error loading {mode} mode prompt: {str(e)}")
raise ConversationDecisionError(f"Error loading {mode} mode prompt: {str(e)}")
# Get LLM model for this focus group
from app.models.focus_group import FocusGroup
focus_group = await FocusGroup.find_by_id(focus_group_id)
llm_model = focus_group.get('llm_model') if focus_group else None
# Get LLM decision
try:
response = await LLMService.generate_content(
prompt=prompt,
temperature=temperature,
model_name=llm_model
)
# Parse the JSON response
decision = LLMService.parse_json_response(response)
# Validate decision structure
if not ConversationDecisionService._validate_decision(decision):
print(f"❌ Invalid decision structure from LLM: {decision}")
raise ConversationDecisionError("Invalid decision structure from LLM")
# Set up logging
import logging
logger = logging.getLogger(__name__)
# Log essential decision info with reasoning
action = decision.get('action', 'unknown')
reasoning = decision.get('reasoning', 'No reasoning provided')
if action == 'participant_respond':
participant_id = decision.get('details', {}).get('participant_id', 'unknown')
logger.info(f"🎯 LLM DECISION RESULT: {action} for participant {participant_id} - {reasoning}")
print(f"✅ Decision: {action} for participant {participant_id}")
else:
logger.info(f"🎯 LLM DECISION RESULT: {action} - {reasoning}")
print(f"✅ Decision: {action}")
return decision
except LLMServiceError as e:
print(f"❌ LLM Service Error: {str(e)}")
raise ConversationDecisionError(f"Error getting LLM decision: {str(e)}")
except Exception as e:
print(f"❌ Unexpected error in LLM processing: {str(e)}")
raise ConversationDecisionError(f"Unexpected error in LLM processing: {str(e)}")
except ConversationDecisionError:
# Re-raise ConversationDecisionError as-is
raise
except Exception as e:
print(f"❌ Unexpected error in conversation decision making: {str(e)}")
import traceback
print(f"❌ Full traceback: {traceback.format_exc()}")
raise ConversationDecisionError(f"Error in conversation decision making: {str(e)}")
@staticmethod
def _validate_decision(decision: Dict[str, Any]) -> bool:
"""Validate that the LLM decision has the correct structure."""
if not isinstance(decision, dict):
return False
# Check required fields
required_fields = ['action', 'reasoning', 'details']
for field in required_fields:
if field not in decision:
return False
# Validate optional discussion_guide_position_id field if present
if 'discussion_guide_position_id' in decision:
if not isinstance(decision['discussion_guide_position_id'], str) or not decision['discussion_guide_position_id'].strip():
return False
# Check action type
valid_actions = ['moderator_speak', 'participant_respond', 'participant_interaction', 'probe_trigger', 'end_session']
if decision['action'] not in valid_actions:
return False
# Validate details based on action type
details = decision['details']
if not isinstance(details, dict):
return False
action = decision['action']
if action == 'moderator_speak':
required_details = ['message_type', 'content']
return all(field in details for field in required_details)
elif action == 'participant_respond':
required_details = ['participant_id', 'call_out', 'topic_context']
return all(field in details for field in required_details)
elif action == 'participant_interaction':
required_details = ['participant_ids', 'interaction_type', 'moderator_prompt']
return all(field in details for field in required_details) and isinstance(details['participant_ids'], list)
elif action == 'probe_trigger':
required_details = ['trigger_type', 'probe_question']
return all(field in details for field in required_details)
elif action == 'end_session':
required_details = ['completion_reason', 'closing_message']
return all(field in details for field in required_details)
return True
@staticmethod
async def select_next_participant(focus_group_id: str, current_topic: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
Use LLM to select the next participant to respond.
Args:
focus_group_id: The focus group ID
current_topic: The current topic being discussed
temperature: LLM temperature for selection
Returns:
Dictionary containing participant selection details
"""
try:
decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature)
if decision['action'] == 'participant_respond':
return {
'participant_id': decision['details']['participant_id'],
'call_out': decision['details']['call_out'],
'topic_context': decision['details']['topic_context'],
'reasoning': decision['reasoning']
}
else:
# If LLM decided on a different action, return that instead
return {
'alternative_action': decision['action'],
'decision': decision
}
except ConversationDecisionError as e:
raise e
except Exception as e:
raise ConversationDecisionError(f"Error selecting participant: {str(e)}")
@staticmethod
async def detect_probe_triggers(focus_group_id: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
Use LLM to detect if probe triggers are needed.
Args:
focus_group_id: The focus group ID
temperature: LLM temperature for detection
Returns:
Dictionary containing probe trigger information
"""
try:
decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature)
if decision['action'] == 'probe_trigger':
return {
'trigger_detected': True,
'trigger_type': decision['details']['trigger_type'],
'probe_question': decision['details']['probe_question'],
'target_participants': decision['details'].get('target_participants', []),
'reasoning': decision['reasoning']
}
else:
return {
'trigger_detected': False,
'alternative_action': decision['action'],
'decision': decision
}
except ConversationDecisionError as e:
raise e
except Exception as e:
raise ConversationDecisionError(f"Error detecting probe triggers: {str(e)}")
@staticmethod
async def generate_moderator_response(focus_group_id: str, context: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
Use LLM to generate appropriate moderator response.
Args:
focus_group_id: The focus group ID
context: Additional context for the response
temperature: LLM temperature for generation
Returns:
Dictionary containing moderator response details
"""
try:
decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature)
if decision['action'] == 'moderator_speak':
return {
'message_type': decision['details']['message_type'],
'content': decision['details']['content'],
'target_participants': decision['details'].get('target_participants', []),
'reasoning': decision['reasoning']
}
else:
return {
'alternative_action': decision['action'],
'decision': decision
}
except ConversationDecisionError as e:
raise e
except Exception as e:
raise ConversationDecisionError(f"Error generating moderator response: {str(e)}")
@staticmethod
async def detect_persona_interactions(focus_group_id: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
Use LLM to detect when personas should interact directly.
Args:
focus_group_id: The focus group ID
temperature: LLM temperature for detection
Returns:
Dictionary containing persona interaction details
"""
try:
decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature)
if decision['action'] == 'participant_interaction':
return {
'interaction_needed': True,
'participant_ids': decision['details']['participant_ids'],
'interaction_type': decision['details']['interaction_type'],
'moderator_prompt': decision['details']['moderator_prompt'],
'reasoning': decision['reasoning']
}
else:
return {
'interaction_needed': False,
'alternative_action': decision['action'],
'decision': decision
}
except ConversationDecisionError as e:
raise e
except Exception as e:
raise ConversationDecisionError(f"Error detecting persona interactions: {str(e)}")
@staticmethod
async def should_end_session(focus_group_id: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
Use LLM to determine if the session should end.
Args:
focus_group_id: The focus group ID
temperature: LLM temperature for decision
Returns:
Dictionary containing session ending decision
"""
try:
decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature)
if decision['action'] == 'end_session':
return {
'should_end': True,
'completion_reason': decision['details']['completion_reason'],
'closing_message': decision['details']['closing_message'],
'reasoning': decision['reasoning']
}
else:
return {
'should_end': False,
'continue_action': decision['action'],
'decision': decision
}
except ConversationDecisionError as e:
raise e
except Exception as e:
raise ConversationDecisionError(f"Error determining session end: {str(e)}")
@staticmethod
async def get_conversation_insights(focus_group_id: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
Use LLM to generate insights about the current conversation state.
Args:
focus_group_id: The focus group ID
temperature: LLM temperature for analysis
Returns:
Dictionary containing conversation insights
"""
try:
# Get conversation context
context = await ConversationContextService.get_full_context(focus_group_id)
# Create a specialized prompt for insights
insight_prompt = f"""
Analyze the current focus group conversation and provide insights:
{ConversationContextService.format_context_for_llm(context)}
Please provide insights in the following JSON format:
{{
"participation_balance": "balanced" | "unbalanced" | "needs_attention",
"conversation_energy": "high" | "medium" | "low",
"topic_engagement": "high" | "medium" | "low",
"sentiment_trend": "positive" | "neutral" | "negative",
"key_themes": ["theme1", "theme2", "theme3"],
"recommendations": ["rec1", "rec2", "rec3"],
"next_suggested_action": "specific recommendation for next step"
}}
"""
# Get LLM model for this focus group
from app.models.focus_group import FocusGroup
focus_group = await FocusGroup.find_by_id(focus_group_id)
llm_model = focus_group.get('llm_model') if focus_group else None
response = await LLMService.generate_content(
prompt=insight_prompt,
temperature=temperature,
model_name=llm_model
)
insights = LLMService.parse_json_response(response)
return insights
except Exception as e:
raise ConversationDecisionError(f"Error generating conversation insights: {str(e)}")