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

821 lines
No EOL
37 KiB
Python
Executable file

"""
AI Moderator Service
This service handles AI-powered moderation of focus group discussions,
including sequential navigation through structured discussion guides.
"""
from typing import Dict, List, Any, Optional, Tuple
import logging
from app.models.focus_group import FocusGroup, emit_websocket_event
from app.services.llm_service import LLMService, LLMServiceError
from app.utils.prompt_loader import load_prompt, PromptLoaderError
from app.utils.discussion_guide_schema import DiscussionGuideValidator, StructuredDiscussionGuide
import json
class AIModeratorService:
"""Service for AI-powered focus group moderation."""
logger = logging.getLogger(__name__)
@staticmethod
def _count_total_items(sections: List[Dict[str, Any]]) -> int:
"""
Count the total number of questions and activities across all sections and subsections.
Args:
sections: List of discussion guide sections
Returns:
Total count of all questions and activities
"""
total_count = 0
for section in sections:
# Count activities in main section
if section.get('activities'):
total_count += len(section['activities'])
# Count questions in main section
if section.get('questions'):
total_count += len(section['questions'])
# Count items in subsections
if section.get('subsections'):
for subsection in section['subsections']:
if subsection.get('activities'):
total_count += len(subsection['activities'])
if subsection.get('questions'):
total_count += len(subsection['questions'])
return total_count
@staticmethod
def _count_completed_items(sections: List[Dict[str, Any]], moderator_position: Dict[str, Any]) -> int:
"""
Count the number of completed questions and activities up to the current moderator position.
Args:
sections: List of discussion guide sections
moderator_position: Current moderator position with section_index, item_index, item_type
Returns:
Count of completed items
"""
current_section_index = moderator_position.get('section_index', 0)
current_item_index = moderator_position.get('item_index', 0)
current_item_type = moderator_position.get('item_type', 'activity')
current_subsection_index = moderator_position.get('subsection_index', None)
completed_count = 0
# Special case: if we're past all sections, everything is completed
if current_section_index >= len(sections):
return AIModeratorService._count_total_items(sections)
for section_idx, section in enumerate(sections):
if section_idx < current_section_index:
# All items in previous sections are completed
if section.get('activities'):
completed_count += len(section['activities'])
if section.get('questions'):
completed_count += len(section['questions'])
# Count all items in subsections of previous sections
if section.get('subsections'):
for subsection in section['subsections']:
if subsection.get('activities'):
completed_count += len(subsection['activities'])
if subsection.get('questions'):
completed_count += len(subsection['questions'])
elif section_idx == current_section_index:
# Current section - count items up to current position
if current_subsection_index is None:
# Working at section level (not in a subsection)
# Count all completed activities first, then questions up to current position
activities = section.get('activities', [])
questions = section.get('questions', [])
if current_item_type == 'activity':
# Currently on an activity - count completed activities up to current position
# If item_index is past all activities, all activities are completed
completed_count += min(current_item_index, len(activities))
# If we're past all activities, also include all questions as completed
if current_item_index >= len(activities):
completed_count += len(questions)
elif current_item_type == 'question':
# Currently on a question - all activities are done, count questions up to current position
completed_count += len(activities)
# If item_index is past all questions, all questions are completed
completed_count += min(current_item_index, len(questions))
else:
# Working within a subsection
# All section-level items are completed
if section.get('activities'):
completed_count += len(section['activities'])
if section.get('questions'):
completed_count += len(section['questions'])
# Count completed subsection items
subsections = section.get('subsections', [])
for subsection_idx, subsection in enumerate(subsections):
if subsection_idx < current_subsection_index:
# All items in previous subsections are completed
if subsection.get('activities'):
completed_count += len(subsection['activities'])
if subsection.get('questions'):
completed_count += len(subsection['questions'])
elif subsection_idx == current_subsection_index:
# Current subsection - count items up to current position
activities = subsection.get('activities', [])
questions = subsection.get('questions', [])
if current_item_type == 'activity':
completed_count += min(current_item_index, len(activities))
# If we're past all activities in subsection, also count all questions
if current_item_index >= len(activities):
completed_count += len(questions)
elif current_item_type == 'question':
completed_count += len(activities)
completed_count += min(current_item_index, len(questions))
# Sections after current section are not yet completed, so we don't count them
return completed_count
@staticmethod
async def get_moderator_status(focus_group_id: str) -> Dict[str, Any]:
"""
Get the current moderator status for a focus group.
Args:
focus_group_id: The focus group ID
Returns:
Dictionary containing current moderator status
"""
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return {"error": "Focus group not found"}
# Get current moderator position
moderator_position = focus_group.get('moderator_position')
# If no moderator position exists, initialize it
if not moderator_position:
moderator_position = {
'section_index': 0,
'item_index': 0,
'item_type': 'activity' # or 'question'
}
# Save the initial position to the database
try:
await FocusGroup.update(focus_group_id, {
'moderator_position': moderator_position
})
print(f"Initialized moderator position for focus group {focus_group_id}")
except Exception as e:
print(f"Warning: Failed to initialize moderator position in database: {e}")
else:
# Reduce log verbosity - only log when position actually changes
if not hasattr(AIModeratorService, '_last_logged_positions'):
AIModeratorService._last_logged_positions = {}
last_position = AIModeratorService._last_logged_positions.get(focus_group_id)
if last_position != moderator_position:
print(f"📍 Moderator position for focus group {focus_group_id}: {moderator_position}")
AIModeratorService._last_logged_positions[focus_group_id] = moderator_position
# Get discussion guide
discussion_guide = focus_group.get('discussionGuide', {})
# If it's a string (old format), return basic status
if isinstance(discussion_guide, str):
return {
"current_section": "Unknown",
"current_item": "Discussion in progress",
"progress": 0,
"total_sections": 0,
"legacy_format": True
}
# Handle structured JSON format
if not discussion_guide or 'sections' not in discussion_guide:
return {"error": "No discussion guide found"}
sections = discussion_guide['sections']
current_section_index = moderator_position.get('section_index', 0)
current_item_index = moderator_position.get('item_index', 0)
# Validate indices
if current_section_index >= len(sections):
current_section_index = len(sections) - 1
moderator_position['section_index'] = current_section_index
current_section = sections[current_section_index]
# Get current item details
current_item = AIModeratorService._get_current_item(
current_section, current_item_index, moderator_position.get('item_type', 'activity')
)
# Calculate granular progress based on individual questions/activities
total_items = AIModeratorService._count_total_items(sections)
completed_items = AIModeratorService._count_completed_items(sections, moderator_position)
# Calculate progress percentage
progress = (completed_items / total_items * 100) if total_items > 0 else 0
return {
"current_section": current_section.get('title', 'Unknown'),
"current_section_id": current_section.get('id', ''),
"current_item": current_item.get('content', 'No content') if current_item else 'End of section',
"current_item_id": current_item.get('id', '') if current_item else '',
"current_item_type": moderator_position.get('item_type', 'activity'),
"progress": progress,
"section_progress": current_item_index,
"total_sections": len(sections),
"moderator_position": moderator_position,
"section_type": current_section.get('type', 'unknown'),
"legacy_format": False
}
except Exception as e:
return {"error": f"Error getting moderator status: {str(e)}"}
@staticmethod
async def advance_discussion(focus_group_id: str) -> Dict[str, Any]:
"""
Advance the discussion to the next item in the guide and generate appropriate moderator response.
Args:
focus_group_id: The focus group ID
Returns:
Dictionary containing the moderator response and updated position
"""
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return {"error": "Focus group not found"}
# Get discussion guide
discussion_guide = focus_group.get('discussionGuide', {})
# Handle legacy markdown format
if isinstance(discussion_guide, str):
return await AIModeratorService._handle_legacy_advance(focus_group_id, discussion_guide)
# Handle structured JSON format
if not discussion_guide or 'sections' not in discussion_guide:
return {"error": "No discussion guide found"}
# Get current position
moderator_position = focus_group.get('moderator_position', {
'section_index': 0,
'item_index': 0,
'item_type': 'activity'
})
# Advance to next item
new_position, next_item, section_info = AIModeratorService._advance_position(
discussion_guide, moderator_position
)
if not next_item:
return {
"message": "Discussion guide completed",
"moderator_response": "Thank you everyone for your participation. We have covered all the topics in our discussion guide. This concludes our focus group session.",
"position": new_position,
"completed": True
}
# Generate moderator response based on the next item
moderator_response = await AIModeratorService._generate_moderator_response(
focus_group_id, next_item, section_info, new_position
)
# Update focus group with new position
print(f"🎯 Advancing moderator position for focus group {focus_group_id}: {new_position}")
update_success = await FocusGroup.update(focus_group_id, {
'moderator_position': new_position
})
if update_success:
print(f"✅ Successfully updated moderator position in database")
# Emit WebSocket event for moderator position change (same pattern as FocusGroup.add_message)
try:
moderator_status = await AIModeratorService.get_moderator_status(focus_group_id)
if "error" not in moderator_status:
await emit_websocket_event('moderator_status_update', focus_group_id, {
'moderator_status': moderator_status
})
AIModeratorService.logger.debug(f"🔔 Emitted moderator_status_update websocket event for focus group {focus_group_id}")
except Exception as e:
AIModeratorService.logger.warning(f"Failed to emit moderator position websocket event: {str(e)}")
else:
print(f"❌ Failed to update moderator position in database")
return {
"message": "Discussion advanced successfully",
"moderator_response": moderator_response,
"position": new_position,
"current_item": next_item,
"section_info": section_info,
"completed": False
}
except Exception as e:
return {"error": f"Error advancing discussion: {str(e)}"}
@staticmethod
async def set_moderator_position(focus_group_id: str, section_id: str, item_id: Optional[str] = None) -> Dict[str, Any]:
"""
Set the moderator position to a specific section and item.
Args:
focus_group_id: The focus group ID
section_id: The section ID to navigate to
item_id: The specific item ID (optional)
Returns:
Dictionary containing the result and new position
"""
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return {"error": "Focus group not found"}
discussion_guide = focus_group.get('discussionGuide', {})
if isinstance(discussion_guide, str):
return {"error": "Manual positioning not supported for legacy format"}
if not discussion_guide or 'sections' not in discussion_guide:
return {"error": "No discussion guide found"}
# Find the section
sections = discussion_guide['sections']
section_index = None
target_section = None
for i, section in enumerate(sections):
if section.get('id') == section_id:
section_index = i
target_section = section
break
if section_index is None:
return {"error": f"Section '{section_id}' not found"}
# Find the item if specified
item_index = 0
item_type = 'activity'
subsection_index = None
if item_id:
# Look for the item in activities first, then questions
found = False
# Check activities
if target_section.get('activities'):
for i, activity in enumerate(target_section['activities']):
if activity.get('id') == item_id:
item_index = i
item_type = 'activity'
found = True
break
# Check questions if not found in activities
if not found and target_section.get('questions'):
for i, question in enumerate(target_section['questions']):
if question.get('id') == item_id:
item_index = i
item_type = 'question'
found = True
break
# Check subsections if not found in main section
if not found and target_section.get('subsections'):
for subsection_idx, subsection in enumerate(target_section['subsections']):
# Check activities in current subsection
if subsection.get('activities'):
for i, activity in enumerate(subsection['activities']):
if activity.get('id') == item_id:
subsection_index = subsection_idx
item_index = i
item_type = 'activity'
found = True
AIModeratorService.logger.info(f"📍 Found item '{item_id}' in subsection {subsection_idx} activity {i}, using subsection_index={subsection_index}, item_index={item_index}")
break
if found:
break
# Check questions in subsection if not found in activities
if not found and subsection.get('questions'):
for i, question in enumerate(subsection['questions']):
if question.get('id') == item_id:
subsection_index = subsection_idx
item_index = i
item_type = 'question'
found = True
AIModeratorService.logger.info(f"📍 Found item '{item_id}' in subsection {subsection_idx} question {i}, using subsection_index={subsection_index}, item_index={item_index}")
break
if found:
break
if not found:
return {"error": f"Item '{item_id}' not found in section '{section_id}'"}
# Set new position
new_position = {
'section_index': section_index,
'item_index': item_index,
'item_type': item_type
}
# Add subsection_index if item is found in a subsection
if subsection_index is not None:
new_position['subsection_index'] = subsection_index
# Log detailed position information for debugging
if subsection_index is not None:
AIModeratorService.logger.info(f"🎯 Setting moderator position: section_index={section_index}, subsection_index={subsection_index}, item_index={item_index}, item_type={item_type}")
else:
AIModeratorService.logger.info(f"🎯 Setting moderator position: section_index={section_index}, item_index={item_index}, item_type={item_type}")
# Update focus group
await FocusGroup.update(focus_group_id, {
'moderator_position': new_position
})
# Emit WebSocket event for moderator position change (same pattern as FocusGroup.add_message)
try:
moderator_status = await AIModeratorService.get_moderator_status(focus_group_id)
if "error" not in moderator_status:
await emit_websocket_event('moderator_status_update', focus_group_id, {
'moderator_status': moderator_status
})
AIModeratorService.logger.debug(f"🔔 Emitted moderator_status_update websocket event for focus group {focus_group_id}")
except Exception as e:
AIModeratorService.logger.warning(f"Failed to emit moderator position websocket event: {str(e)}")
return {
"message": "Moderator position updated successfully",
"position": new_position,
"section_title": target_section.get('title', 'Unknown'),
"section_id": section_id
}
except Exception as e:
return {"error": f"Error setting moderator position: {str(e)}"}
@staticmethod
def _get_current_item(section: Dict[str, Any], item_index: int, item_type: str) -> Optional[Dict[str, Any]]:
"""Get the current item from a section."""
if item_type == 'activity' and section.get('activities'):
activities = section['activities']
if item_index < len(activities):
return activities[item_index]
elif item_type == 'question' and section.get('questions'):
questions = section['questions']
if item_index < len(questions):
return questions[item_index]
return None
@staticmethod
def _advance_position(discussion_guide: Dict[str, Any], current_position: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]], Dict[str, Any]]:
"""
Advance the position to the next item in the discussion guide.
Returns:
Tuple of (new_position, next_item, section_info)
"""
sections = discussion_guide['sections']
section_index = current_position.get('section_index', 0)
item_index = current_position.get('item_index', 0)
item_type = current_position.get('item_type', 'activity')
# Check if we're at the end
if section_index >= len(sections):
return current_position, None, {}
current_section = sections[section_index]
# Try to advance within the current section
if item_type == 'activity' and current_section.get('activities'):
activities = current_section['activities']
if item_index + 1 < len(activities):
# Next activity in same section
new_position = {
'section_index': section_index,
'item_index': item_index + 1,
'item_type': 'activity'
}
return new_position, activities[item_index + 1], {'title': current_section.get('title', ''), 'type': current_section.get('type', '')}
else:
# Move to questions if available
if current_section.get('questions'):
new_position = {
'section_index': section_index,
'item_index': 0,
'item_type': 'question'
}
return new_position, current_section['questions'][0], {'title': current_section.get('title', ''), 'type': current_section.get('type', '')}
elif item_type == 'question' and current_section.get('questions'):
questions = current_section['questions']
if item_index + 1 < len(questions):
# Next question in same section
new_position = {
'section_index': section_index,
'item_index': item_index + 1,
'item_type': 'question'
}
return new_position, questions[item_index + 1], {'title': current_section.get('title', ''), 'type': current_section.get('type', '')}
# Move to next section
next_section_index = section_index + 1
if next_section_index >= len(sections):
return current_position, None, {} # End of guide
next_section = sections[next_section_index]
# Start with activities if available, otherwise questions
if next_section.get('activities'):
new_position = {
'section_index': next_section_index,
'item_index': 0,
'item_type': 'activity'
}
return new_position, next_section['activities'][0], {'title': next_section.get('title', ''), 'type': next_section.get('type', '')}
elif next_section.get('questions'):
new_position = {
'section_index': next_section_index,
'item_index': 0,
'item_type': 'question'
}
return new_position, next_section['questions'][0], {'title': next_section.get('title', ''), 'type': next_section.get('type', '')}
# Section has no items, skip to next
return AIModeratorService._advance_position(discussion_guide, {
'section_index': next_section_index,
'item_index': 0,
'item_type': 'activity'
})
@staticmethod
async def _generate_moderator_response(focus_group_id: str, item: Dict[str, Any], section_info: Dict[str, Any], position: Dict[str, Any]) -> str:
"""
Generate an appropriate moderator response for the current item.
Args:
focus_group_id: The focus group ID
item: The current item (activity or question)
section_info: Information about the current section
position: Current position in the guide
Returns:
Generated moderator response
"""
try:
# Get previous messages for context
messages = await FocusGroup.get_messages(focus_group_id)
recent_messages = messages[-10:] if messages else [] # Last 10 messages
# Format context
context = {
'item_content': item.get('content', ''),
'item_type': item.get('type', 'unknown'),
'section_title': section_info.get('title', 'Unknown'),
'section_type': section_info.get('type', 'unknown'),
'recent_messages': AIModeratorService._format_messages_for_context(recent_messages),
'probes': item.get('probes', []) if item.get('probes') else [],
'time_limit': item.get('time_limit', 0) if item.get('time_limit') else 0
}
# Load moderator prompt
prompt = load_prompt('ai-moderator-system', context)
# Get LLM model for this focus group
focus_group = await FocusGroup.find_by_id(focus_group_id)
llm_model = focus_group.get('llm_model') if focus_group else None
# Generate response
response = await LLMService.generate_content(
prompt=prompt,
temperature=0.7,
model_name=llm_model
)
return response.strip()
except Exception as e:
# Fallback to item content if generation fails
return item.get('content', 'Let\'s continue with our discussion.')
@staticmethod
def _format_messages_for_context(messages: List[Dict[str, Any]]) -> str:
"""Format messages for use in moderator context."""
if not messages:
return "No previous messages."
formatted = []
for msg in messages:
sender = msg.get('senderId', 'Unknown')
text = msg.get('text', '')
msg_type = msg.get('type', 'response')
if msg_type == 'question':
formatted.append(f"MODERATOR: {text}")
elif msg_type == 'system':
formatted.append(f"SYSTEM: {text}")
else:
formatted.append(f"{sender}: {text}")
return "\n".join(formatted)
@staticmethod
async def _handle_legacy_advance(focus_group_id: str, discussion_guide: str) -> Dict[str, Any]:
"""Handle advancement for legacy markdown format guides."""
# For legacy format, we'll generate a generic moderator response
# This is a fallback for older discussion guides
try:
# Get recent messages for context
messages = await FocusGroup.get_messages(focus_group_id)
recent_messages = messages[-5:] if messages else []
# Create a simple context
context = {
'discussion_guide': discussion_guide,
'recent_messages': AIModeratorService._format_messages_for_context(recent_messages),
'legacy_mode': True
}
# Try to load and use the moderator prompt
prompt = load_prompt('ai-moderator-system', context)
# Get LLM model for this focus group
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=prompt,
temperature=0.7,
model_name=llm_model
)
return {
"message": "Discussion advanced (legacy mode)",
"moderator_response": response.strip(),
"legacy_format": True
}
except Exception as e:
return {
"message": "Discussion advanced (legacy mode)",
"moderator_response": "Let's continue with our discussion. What are your thoughts on the current topic?",
"legacy_format": True,
"error": str(e)
}
@staticmethod
async def end_session_with_concluding_statement(focus_group_id: str, reason: str = 'session_ended') -> Dict[str, Any]:
"""
End a focus group session with a concluding moderator statement.
Args:
focus_group_id: The focus group ID
reason: Reason for ending ('manual_stop', 'auto_complete', 'timeout', etc.)
Returns:
Dictionary containing the concluding statement and session end confirmation
"""
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return {"error": "Focus group not found"}
# Generate concluding statement
concluding_message = await AIModeratorService._generate_concluding_statement(
focus_group_id, reason
)
# Save the concluding message
message_data = {
"text": concluding_message,
"type": "conclusion",
"senderId": "moderator"
}
message_id = await FocusGroup.add_message(focus_group_id, message_data)
if not message_id:
print(f"Warning: Failed to save concluding message for focus group {focus_group_id}")
# Update focus group status to completed
await FocusGroup.update(focus_group_id, {
'status': 'completed'
})
# Add mode event for all AI session conclusions
# This includes auto_complete, natural_completion, discussion_guide_completed, manual_stop, etc.
mode_event_id = await FocusGroup.add_mode_event(
focus_group_id=focus_group_id,
event_type='ai_session_concluded'
)
if mode_event_id:
print(f"🎯 Added AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
else:
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
print(f"🎬 Session ended for focus group {focus_group_id} with reason: {reason}")
return {
"message": "Session ended successfully",
"concluding_statement": concluding_message,
"reason": reason,
"focus_group_id": focus_group_id,
"message_id": message_id,
"status": "completed"
}
except Exception as e:
return {"error": f"Error ending session: {str(e)}"}
@staticmethod
async def _generate_concluding_statement(focus_group_id: str, reason: str) -> str:
"""
Generate an appropriate concluding statement for the session.
Args:
focus_group_id: The focus group ID
reason: Reason for ending the session
Returns:
Generated concluding statement
"""
try:
# Get focus group details for context
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return AIModeratorService._get_fallback_concluding_message(reason)
# Get recent messages for context
messages = await FocusGroup.get_messages(focus_group_id)
recent_messages = messages[-5:] if messages else []
# Create context for LLM
context = {
'focus_group_name': focus_group.get('name', 'focus group'),
'focus_group_topic': focus_group.get('topic', 'discussion'),
'ending_reason': reason,
'recent_messages': AIModeratorService._format_messages_for_context(recent_messages),
'session_concluded': True
}
# Try to generate with LLM using moderator prompt
prompt = load_prompt('ai-moderator-system', context)
# Get LLM model for this focus group
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=prompt,
temperature=0.5, # Lower temperature for more consistent, professional responses
model_name=llm_model
)
return response.strip()
except Exception as e:
print(f"Warning: Failed to generate concluding statement with LLM: {e}")
return AIModeratorService._get_fallback_concluding_message(reason)
@staticmethod
def _get_fallback_concluding_message(reason: str) -> str:
"""
Get a fallback concluding message when LLM generation fails.
Args:
reason: Reason for ending the session
Returns:
Appropriate fallback message
"""
messages = {
'manual_stop': "The focus group session has now ended. Thank you for your participation and valuable insights.",
'auto_complete': "We have covered all topics in our discussion guide. Thank you everyone for your thoughtful participation in today's focus group.",
'timeout': "Our time for today's session has concluded. Thank you all for sharing your perspectives and contributing to this discussion.",
'session_ended': "The focus group session has now ended. Thank you for your participation."
}
return messages.get(reason, messages['session_ended'])