← back to Dear Bubbe Admin

backend/models/Conversation.js

319 lines

const mongoose = require('mongoose')

const conversationSchema = new mongoose.Schema({
  userId: {
    type: String,
    required: true,
    index: true
  },
  sessionId: {
    type: String,
    required: true,
    index: true
  },
  timestamp: {
    type: Date,
    default: Date.now,
    index: true
  },
  message: {
    text: { type: String, required: true },
    length: { type: Number },
    language: { type: String, default: 'en' },
    sentiment: { 
      type: String, 
      enum: ['positive', 'neutral', 'negative'], 
      default: 'neutral' 
    },
    toxicity: {
      score: { type: Number, min: 0, max: 1, default: 0 },
      categories: [{ type: String }] // hate, insult, threat, etc.
    }
  },
  response: {
    text: { type: String, required: true },
    mode: { type: String, default: 'bubbe' }, // bubbe, meshugana, comedian
    length: { type: Number },
    generationTime: { type: Number }, // in ms
    model: { type: String, default: 'claude-3-haiku' },
    voiceGenerated: { type: Boolean, default: false },
    voiceUrl: { type: String }
  },
  context: {
    conversationNumber: { type: Number, default: 1 },
    timeSinceLastMessage: { type: Number }, // in seconds
    previousTopic: { type: String },
    userProfile: { type: mongoose.Schema.Types.Mixed }, // Snapshot of user profile
    location: { type: mongoose.Schema.Types.Mixed }
  },
  topics: [{ type: String }], // Extracted topics like 'marriage', 'career', 'food'
  
  analysis: {
    responseTime: { type: Number }, // in ms
    userEngagement: { 
      type: String, 
      enum: ['low', 'medium', 'high'], 
      default: 'medium' 
    },
    topicShift: { type: Boolean, default: false },
    escalation: { type: Boolean, default: false }, // If conversation became heated
    satisfaction: { 
      type: String, 
      enum: ['satisfied', 'neutral', 'frustrated'], 
      default: 'neutral' 
    }
  },
  
  flags: {
    flaggedContent: { type: Boolean, default: false },
    flagReason: [{ type: String }], // antisemitism, racism, harassment, etc.
    alertsTriggered: { type: Number, default: 0 },
    reviewed: { type: Boolean, default: false },
    reviewedBy: { type: String }, // Admin user ID
    reviewedAt: { type: Date },
    reviewNotes: { type: String }
  },
  
  technicalData: {
    ip: { type: String, index: true },
    userAgent: { type: String },
    referer: { type: String },
    apiEndpoint: { type: String },
    requestTime: { type: Number },
    errors: [{
      timestamp: { type: Date },
      error: { type: String },
      code: { type: String }
    }]
  }
}, {
  timestamps: true
})

// Indexes for performance
conversationSchema.index({ timestamp: -1 })
conversationSchema.index({ userId: 1, timestamp: -1 })
conversationSchema.index({ sessionId: 1, 'context.conversationNumber': 1 })
conversationSchema.index({ 'flags.flaggedContent': 1, timestamp: -1 })
conversationSchema.index({ topics: 1 })

// Pre-save middleware to extract topics and analyze content
conversationSchema.pre('save', function(next) {
  // Calculate message length
  if (this.message.text) {
    this.message.length = this.message.text.length
  }
  
  if (this.response.text) {
    this.response.length = this.response.text.length
  }
  
  // Extract topics from message and response text
  if (!this.topics || this.topics.length === 0) {
    this.topics = this.extractTopics(this.message.text + ' ' + this.response.text)
  }
  
  // Analyze sentiment
  this.message.sentiment = this.analyzeSentiment(this.message.text)
  
  next()
})

// Method to extract topics from text
conversationSchema.methods.extractTopics = function(text) {
  const topics = []
  const lowerText = text.toLowerCase()
  
  const topicPatterns = [
    { pattern: /\b(marriage|wedding|engaged|spouse|husband|wife|marry)\b/gi, topic: 'marriage' },
    { pattern: /\b(kids?|children|baby|babies|pregnant|family)\b/gi, topic: 'children' },
    { pattern: /\b(job|work|career|salary|boss|company|employment)\b/gi, topic: 'career' },
    { pattern: /\b(money|income|salary|debt|savings|rent|cost|expensive)\b/gi, topic: 'finances' },
    { pattern: /\b(school|university|college|degree|education|student)\b/gi, topic: 'education' },
    { pattern: /\b(health|sick|doctor|medicine|hospital|diet)\b/gi, topic: 'health' },
    { pattern: /\b(food|cooking|eating|restaurant|recipe)\b/gi, topic: 'food' },
    { pattern: /\b(dating|relationship|boyfriend|girlfriend|single|love)\b/gi, topic: 'relationships' },
    { pattern: /\b(jewish|torah|synagogue|shabbat|kosher|yiddish)\b/gi, topic: 'religion' },
    { pattern: /\b(news|israel|politics|election|trump|biden)\b/gi, topic: 'current_events' },
    { pattern: /\b(weather|temperature|rain|snow|storm|forecast)\b/gi, topic: 'weather' },
    { pattern: /\b(sports|game|team|basketball|football|baseball)\b/gi, topic: 'sports' },
    { pattern: /\b(travel|vacation|trip|hotel|flight)\b/gi, topic: 'travel' },
    { pattern: /\b(house|apartment|rent|buy|move|neighborhood)\b/gi, topic: 'housing' }
  ]
  
  topicPatterns.forEach(({ pattern, topic }) => {
    if (pattern.test(lowerText) && !topics.includes(topic)) {
      topics.push(topic)
    }
  })
  
  return topics
}

// Method to analyze sentiment
conversationSchema.methods.analyzeSentiment = function(text) {
  const positiveWords = [
    'happy', 'great', 'good', 'excellent', 'wonderful', 'amazing', 'love', 'fantastic',
    'awesome', 'perfect', 'beautiful', 'brilliant', 'outstanding', 'magnificent'
  ]
  
  const negativeWords = [
    'sad', 'bad', 'terrible', 'awful', 'hate', 'horrible', 'disgusting', 'worst',
    'annoying', 'frustrated', 'angry', 'disappointed', 'upset', 'depressed'
  ]
  
  const lowerText = text.toLowerCase()
  
  let positiveScore = 0
  let negativeScore = 0
  
  positiveWords.forEach(word => {
    const regex = new RegExp(`\\b${word}\\b`, 'gi')
    const matches = (lowerText.match(regex) || []).length
    positiveScore += matches
  })
  
  negativeWords.forEach(word => {
    const regex = new RegExp(`\\b${word}\\b`, 'gi')
    const matches = (lowerText.match(regex) || []).length
    negativeScore += matches
  })
  
  if (positiveScore > negativeScore) return 'positive'
  if (negativeScore > positiveScore) return 'negative'
  return 'neutral'
}

// Method to flag conversation
conversationSchema.methods.flagContent = function(reasons, adminId = null) {
  this.flags.flaggedContent = true
  this.flags.flagReason = Array.isArray(reasons) ? reasons : [reasons]
  this.flags.alertsTriggered++
  
  if (adminId) {
    this.flags.reviewedBy = adminId
    this.flags.reviewedAt = new Date()
    this.flags.reviewed = true
  }
  
  return this.save()
}

// Static method to get conversation statistics
conversationSchema.statics.getStats = function(timeframe = '24h') {
  const now = new Date()
  let startTime
  
  switch (timeframe) {
    case '1h':
      startTime = new Date(now - 60 * 60 * 1000)
      break
    case '24h':
      startTime = new Date(now - 24 * 60 * 60 * 1000)
      break
    case '7d':
      startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
      break
    case '30d':
      startTime = new Date(now - 30 * 24 * 60 * 60 * 1000)
      break
    default:
      startTime = new Date(now - 24 * 60 * 60 * 1000)
  }
  
  return this.aggregate([
    {
      $match: {
        timestamp: { $gte: startTime }
      }
    },
    {
      $group: {
        _id: null,
        totalConversations: { $sum: 1 },
        uniqueUsers: { $addToSet: '$userId' },
        averageResponseTime: { $avg: '$analysis.responseTime' },
        topicDistribution: { $push: '$topics' },
        sentimentDistribution: {
          positive: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'positive'] }, 1, 0] } },
          neutral: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'neutral'] }, 1, 0] } },
          negative: { $sum: { $cond: [{ $eq: ['$message.sentiment', 'negative'] }, 1, 0] } }
        },
        modeDistribution: {
          bubbe: { $sum: { $cond: [{ $eq: ['$response.mode', 'bubbe'] }, 1, 0] } },
          meshugana: { $sum: { $cond: [{ $eq: ['$response.mode', 'meshugana'] }, 1, 0] } },
          comedian: { $sum: { $cond: [{ $eq: ['$response.mode', 'comedian'] }, 1, 0] } }
        },
        flaggedConversations: { $sum: { $cond: ['$flags.flaggedContent', 1, 0] } },
        voiceUsage: { $sum: { $cond: ['$response.voiceGenerated', 1, 0] } }
      }
    },
    {
      $addFields: {
        uniqueUserCount: { $size: '$uniqueUsers' }
      }
    }
  ])
}

// Static method to get top topics
conversationSchema.statics.getTopTopics = function(limit = 10, timeframe = '24h') {
  const now = new Date()
  let startTime
  
  switch (timeframe) {
    case '1h':
      startTime = new Date(now - 60 * 60 * 1000)
      break
    case '24h':
      startTime = new Date(now - 24 * 60 * 60 * 1000)
      break
    case '7d':
      startTime = new Date(now - 7 * 24 * 60 * 60 * 1000)
      break
    default:
      startTime = new Date(now - 24 * 60 * 60 * 1000)
  }
  
  return this.aggregate([
    {
      $match: {
        timestamp: { $gte: startTime }
      }
    },
    {
      $unwind: '$topics'
    },
    {
      $group: {
        _id: '$topics',
        count: { $sum: 1 },
        uniqueUsers: { $addToSet: '$userId' }
      }
    },
    {
      $addFields: {
        uniqueUserCount: { $size: '$uniqueUsers' }
      }
    },
    {
      $sort: { count: -1 }
    },
    {
      $limit: limit
    }
  ])
}

// Static method to get flagged conversations
conversationSchema.statics.getFlagged = function(limit = 50) {
  return this.find({
    'flags.flaggedContent': true
  })
  .sort({ timestamp: -1 })
  .limit(limit)
  .populate('userId', 'profile.name profile.preferredName')
  .lean()
}

module.exports = mongoose.model('Conversation', conversationSchema)