UNPKG

199bio-mcp-limitless-server

Version:

Scientifically Validated Speech Vitality Index for Limitless Pendant. Empirically validated metrics from 2,500+ conversation segments with transparent reliability assessment. Peer-reviewed methodology for clinical research applications.

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