821 lines
No EOL
37 KiB
Python
Executable file
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']) |