UNPKG

199bio-mcp-limitless-server

Version:

Enhanced MCP server for Limitless Pendant with advanced conversation analysis, meeting detection, action item extraction, and comprehensive analytics. Features smart pagination for handling large datasets.

349 lines 15.8 kB
export class ValidatedSpeechVitalityAnalyzer { static RESPONSIVE_WORDS = /^(yeah|yes|okay|ok|really|oh|wow|huh|what|no|right|sure|exactly|absolutely|definitely|totally|mmm|uh|um|aha|mhm)\.?$/i; static VERSION = "2.0.0-validated"; /** * Main analysis entry point - scientifically validated approach */ static analyze(lifelogs) { // Extract and categorize all segments const allSegments = this.extractAllSegments(lifelogs); const categorized = this.categorizeSegments(allSegments); // Analyze each validated dimension const engagement = this.analyzeEngagement(allSegments); const fluency = this.analyzeFluency(categorized.medium.concat(categorized.long)); const interaction = this.analyzeInteraction(allSegments); const context = this.detectContext(engagement, interaction, fluency); const dataQuality = this.assessDataQuality(categorized, allSegments); // Calculate composite scores const scores = this.calculateCompositeScores(engagement, fluency, interaction, dataQuality); return { engagement, fluency, interaction, context, dataQuality, overallScore: scores.overall, engagementScore: scores.engagement, fluencyScore: scores.fluency, interactionScore: scores.interaction, analysisTimestamp: new Date(), conversationDuration: this.calculateTotalDuration(lifelogs), analysisVersion: this.VERSION }; } /** * Extract all segments from lifelogs with proper validation */ static extractAllSegments(lifelogs) { const segments = []; for (const lifelog of lifelogs) { if (!lifelog.contents) continue; for (const node of lifelog.contents) { if (!node.content || node.startOffsetMs === undefined || node.endOffsetMs === undefined) { continue; } const duration = node.endOffsetMs - node.startOffsetMs; const content = node.content.trim(); const wordCount = content.split(/\s+/).filter(w => w.length > 0).length; const wpm = wordCount > 0 && duration > 0 ? (wordCount / (duration / 60000)) : 0; segments.push({ content, duration, wordCount, wpm, speakerIdentifier: node.speakerIdentifier || null, speakerName: node.speakerName || null, startOffset: node.startOffsetMs, endOffset: node.endOffsetMs, category: this.categorizeSegment(duration, wordCount) }); } } return segments.sort((a, b) => a.startOffset - b.startOffset); } /** * Categorize individual segment by duration and content */ static categorizeSegment(duration, wordCount) { if (duration <= 0) return 'problematic'; if (duration <= 100) return 'micro'; if (duration <= 800) return 'short'; if (duration <= 8000) return 'medium'; return 'long'; } /** * Group segments by category for analysis */ static categorizeSegments(segments) { return { micro: segments.filter(s => s.category === 'micro'), short: segments.filter(s => s.category === 'short'), medium: segments.filter(s => s.category === 'medium'), long: segments.filter(s => s.category === 'long'), problematic: segments.filter(s => s.category === 'problematic') }; } /** * Analyze engagement through micro-responsiveness and turn-taking * Validated: 7-14% micro baseline, >20% = highly engaged */ static analyzeEngagement(segments) { const microSegments = segments.filter(s => s.category === 'micro'); const responsiveWords = microSegments.filter(s => this.RESPONSIVE_WORDS.test(s.content)); // Calculate turn transitions const transitions = this.calculateTurnTransitions(segments); const quickResponses = transitions.filter(t => t.responseType === 'quick'); const totalTransitions = transitions.length; const avgResponseTime = totalTransitions > 0 ? transitions.reduce((sum, t) => sum + t.gapDuration, 0) / totalTransitions : 0; const medianResponseTime = this.median(transitions.map(t => t.gapDuration)); return { microResponseCount: microSegments.length, microResponseRate: segments.length > 0 ? microSegments.length / segments.length : 0, responsiveWordCount: responsiveWords.length, responsiveWordRate: microSegments.length > 0 ? responsiveWords.length / microSegments.length : 0, totalTransitions, quickResponseCount: quickResponses.length, quickResponseRatio: totalTransitions > 0 ? quickResponses.length / totalTransitions : 0, averageResponseTime: avgResponseTime, medianResponseTime }; } /** * Analyze fluency using filtered, validated segments only * Validated: 47-58% segments suitable, 100-250 WPM realistic */ static analyzeFluency(validSegments) { // Filter for WPM calculation: ≥800ms, ≤30s, ≥5 words, reasonable WPM const wpmCandidates = validSegments.filter(s => s.duration >= 800 && s.duration <= 30000 && s.wordCount >= 5 && s.wpm > 0); const wpmValues = wpmCandidates.map(s => s.wpm); const realisticWPM = wpmValues.filter(wpm => wpm >= 100 && wpm <= 250); const meanWPM = this.mean(wpmValues); const medianWPM = this.median(wpmValues); const wpmStdDev = this.standardDeviation(wpmValues); const durations = validSegments.map(s => s.duration); const avgDuration = this.mean(durations); const medianDuration = this.median(durations); const durationVariability = durations.length > 0 ? this.standardDeviation(durations) / avgDuration : 0; return { validSegmentCount: wpmCandidates.length, validSegmentRatio: validSegments.length > 0 ? wpmCandidates.length / validSegments.length : 0, meanWPM, medianWPM, wpmStandardDeviation: wpmStdDev, wpmConsistency: meanWPM > 0 ? Math.max(0, 1 - (wpmStdDev / meanWPM)) : 0, realisticWPMRatio: wpmValues.length > 0 ? realisticWPM.length / wpmValues.length : 0, avgSegmentDuration: avgDuration, medianSegmentDuration: medianDuration, segmentDurationVariability: durationVariability }; } /** * Analyze interaction patterns and conversational dynamics */ static analyzeInteraction(segments) { const speakers = new Set(segments.map(s => s.speakerIdentifier || s.speakerName || 'unknown')); const transitions = this.calculateTurnTransitions(segments); // Calculate speaking times const userSegments = segments.filter(s => s.speakerIdentifier === 'user' || s.speakerName === 'You'); const userSpeakingTime = userSegments.reduce((sum, s) => sum + s.duration, 0); const totalSpeakingTime = segments.reduce((sum, s) => sum + s.duration, 0); // Detect anomalies const overlaps = transitions.filter(t => t.gapDuration < 0).length; const longPauses = transitions.filter(t => t.gapDuration > 3000).length; // Calculate silence ratio (gaps vs speaking time) const totalGapTime = transitions.reduce((sum, t) => sum + Math.max(0, t.gapDuration), 0); const silenceRatio = totalSpeakingTime > 0 ? totalGapTime / (totalSpeakingTime + totalGapTime) : 0; const userSpeakingRatio = totalSpeakingTime > 0 ? userSpeakingTime / totalSpeakingTime : 0; const conversationBalance = 1 - Math.abs(0.5 - userSpeakingRatio); return { totalSpeakers: speakers.size, speakerTransitions: transitions.length, userSpeakingTime, totalValidSpeakingTime: totalSpeakingTime, userSpeakingRatio, conversationBalance, overlapsDetected: overlaps, longPauseCount: longPauses, silenceRatio }; } /** * Calculate turn transitions between speakers */ static calculateTurnTransitions(segments) { const transitions = []; for (let i = 1; i < segments.length; i++) { const prev = segments[i - 1]; const current = segments[i]; const prevSpeaker = prev.speakerIdentifier || prev.speakerName || 'unknown'; const currentSpeaker = current.speakerIdentifier || current.speakerName || 'unknown'; // Only count actual speaker changes if (prevSpeaker !== currentSpeaker) { const gap = current.startOffset - prev.endOffset; let responseType; if (gap < 500) responseType = 'quick'; else if (gap <= 1500) responseType = 'normal'; else if (gap <= 3000) responseType = 'slow'; else responseType = 'delayed'; transitions.push({ gapDuration: gap, fromSpeaker: prevSpeaker, toSpeaker: currentSpeaker, responseType }); } } return transitions; } /** * Detect conversation context based on validated patterns */ static detectContext(engagement, interaction, fluency) { const indicators = []; let type = 'unknown'; let confidence = 0; // Automated detection (e.g., elevator announcements) if (engagement.microResponseRate < 0.05 && interaction.speakerTransitions < 3) { type = 'automated'; confidence = 0.9; indicators.push('No responsiveness', 'Minimal speaker changes'); } // Presentation detection else if (engagement.quickResponseRatio < 0.1 && interaction.userSpeakingRatio < 0.2) { type = 'presentation'; confidence = 0.8; indicators.push('Few quick responses', 'One-sided speaking'); } // Active discussion else if (engagement.quickResponseRatio > 0.4 && interaction.speakerTransitions > 20) { type = 'discussion'; confidence = 0.85; indicators.push('High responsiveness', 'Frequent speaker changes'); } // Casual conversation else if (engagement.microResponseRate > 0.15 && interaction.conversationBalance > 0.3) { type = 'casual'; confidence = 0.7; indicators.push('Good responsiveness', 'Balanced participation'); } return { type, confidence, indicators }; } /** * Assess overall data quality and reliability */ static assessDataQuality(categorized, allSegments) { const total = allSegments.length; const anomalies = { zeroLengthSegments: allSegments.filter(s => s.duration <= 0).length, negativeGaps: 0, // Will be calculated in turn transitions suspiciouslyLongGaps: 0, // Will be calculated in turn transitions unrealisticWPM: allSegments.filter(s => s.wpm > 500).length }; // Calculate reliability based on usable data percentage const usableSegments = categorized.medium.length + categorized.long.length; const usableRatio = total > 0 ? usableSegments / total : 0; let dataReliability; let confidenceScore; if (usableRatio > 0.6 && anomalies.unrealisticWPM / total < 0.1) { dataReliability = 'high'; confidenceScore = 85; } else if (usableRatio > 0.4 && anomalies.unrealisticWPM / total < 0.2) { dataReliability = 'medium'; confidenceScore = 65; } else { dataReliability = 'low'; confidenceScore = 35; } return { totalSegments: total, categorizedSegments: { micro: categorized.micro.length, short: categorized.short.length, medium: categorized.medium.length, long: categorized.long.length, problematic: categorized.problematic.length }, anomalies, dataReliability, confidenceScore }; } /** * Calculate composite scores (0-100) from validated metrics */ static calculateCompositeScores(engagement, fluency, interaction, dataQuality) { // Engagement score (0-100) const engagementScore = Math.round((engagement.microResponseRate * 40) + // High responsiveness (engagement.quickResponseRatio * 30) + // Quick responses (Math.min(1, engagement.responsiveWordRate) * 30) // Responsive words ) * 100; // Fluency score (0-100) const wpmNormalized = fluency.meanWPM > 0 ? Math.min(1, Math.max(0, (fluency.meanWPM - 100) / 150)) // 100-250 WPM range : 0; const fluencyScore = Math.round((wpmNormalized * 50) + // Appropriate speaking rate (fluency.wpmConsistency * 30) + // Consistent delivery (fluency.realisticWPMRatio * 20) // Reliable measurements ) * 100; // Interaction score (0-100) const interactionScore = Math.round((interaction.conversationBalance * 40) + // Balanced participation (Math.min(1, interaction.speakerTransitions / 50) * 30) + // Active dialogue (Math.max(0, 1 - interaction.silenceRatio) * 30) // Good flow ) * 100; // Overall score weighted by data quality const rawOverall = (engagementScore * 0.4) + (fluencyScore * 0.35) + (interactionScore * 0.25); const qualityMultiplier = dataQuality.confidenceScore / 100; const overallScore = Math.round(rawOverall * qualityMultiplier); return { engagement: Math.min(100, Math.max(0, engagementScore)), fluency: Math.min(100, Math.max(0, fluencyScore)), interaction: Math.min(100, Math.max(0, interactionScore)), overall: Math.min(100, Math.max(0, overallScore)) }; } /** * Calculate total conversation duration from lifelogs */ static calculateTotalDuration(lifelogs) { return lifelogs.reduce((total, log) => { const start = new Date(log.startTime).getTime(); const end = new Date(log.endTime).getTime(); return total + (end - start); }, 0); } // Utility functions static mean(values) { return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; } static median(values) { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } static standardDeviation(values) { if (values.length === 0) return 0; const mean = this.mean(values); const squaredDiffs = values.map(v => Math.pow(v - mean, 2)); return Math.sqrt(this.mean(squaredDiffs)); } } //# sourceMappingURL=speech-vitality-index.js.map