Skip to main content
Glama
circumplex-analyzer.ts12.5 kB
/** * Circumplex Emotion Analyzer * * Implements the Circumplex Model of Affect for emotion detection. * Analyzes text across three dimensions: * - Valence: Emotional positivity/negativity (-1 to +1) * - Arousal: Emotional intensity/activation (0 to 1) * - Dominance: Sense of control/power (-1 to +1) * * Uses a lexicon-based approach for fast, deterministic emotion detection. * * Requirements: 9.1, 9.2, 9.3, 9.4, 9.5 */ import type { CircumplexState, EmotionAnalysisOptions, EmotionModel, EmotionalFeatures, } from "./types"; /** * Emotion lexicons for each dimension */ const VALENCE_POSITIVE = new Set([ "happy", "joy", "joyful", "excited", "wonderful", "great", "excellent", "fantastic", "amazing", "love", "loving", "loved", "delighted", "pleased", "glad", "cheerful", "thrilled", "ecstatic", "elated", "blissful", "content", "satisfied", "grateful", "thankful", "proud", "confident", "optimistic", "hopeful", "enthusiastic", "passionate", ]); const VALENCE_NEGATIVE = new Set([ "sad", "sadness", "angry", "anger", "terrible", "awful", "horrible", "hate", "hating", "hated", "disappointed", "disappointing", "upset", "unhappy", "miserable", "depressed", "frustrated", "annoyed", "irritated", "furious", "enraged", "disgusted", "disgusting", "ashamed", "guilty", "regretful", "worried", "anxious", "fearful", "scared", "terrified", ]); const AROUSAL_HIGH = new Set([ "furious", "enraged", "ecstatic", "thrilled", "terrified", "panicked", "excited", "enthusiastic", "passionate", "intense", "overwhelming", "explosive", "frantic", "hysterical", "agitated", "alarmed", "shocked", "astonished", "amazed", "exhilarated", "angry", "outraged", "extremely", ]); const AROUSAL_MEDIUM = new Set([ "concerned", "worried", "anxious", "interested", "curious", "alert", "attentive", ]); const AROUSAL_LOW = new Set([ "calm", "calming", "relaxed", "peaceful", "serene", "tranquil", "bored", "boring", "tired", "sleepy", "drowsy", "lethargic", "sluggish", "apathetic", "indifferent", "passive", "quiet", "still", "gentle", "mild", ]); const DOMINANCE_HIGH = new Set([ "control", "controlling", "controlled", "power", "powerful", "confident", "command", "commanding", "authority", "authoritative", "dominant", "dominating", "strong", "capable", "competent", "assertive", "decisive", "determined", "independent", "autonomous", ]); const DOMINANCE_LOW = new Set([ "helpless", "powerless", "weak", "submissive", "vulnerable", "dependent", "incapable", "incompetent", "uncertain", "insecure", "timid", "meek", "passive", "controlled", "dominated", "overwhelmed", "defeated", "hopeless", "trapped", "stuck", ]); /** * Intensity markers that amplify emotional arousal */ const INTENSITY_AMPLIFIERS = new Set([ "very", "extremely", "absolutely", "completely", "totally", "utterly", "incredibly", "remarkably", "exceptionally", "extraordinarily", ]); /** * CircumplexEmotionAnalyzer * * Analyzes text to detect emotional dimensions using the Circumplex Model. */ export class CircumplexEmotionAnalyzer { private readonly cache: Map<string, CircumplexState>; private readonly maxCacheSize: number = 1000; constructor(_model: EmotionModel) { // Model parameter kept for future extensibility (e.g., ML models) this.cache = new Map(); } /** * Detect valence (emotional positivity/negativity) * @param text - Text to analyze * @returns Valence score from -1 (very negative) to +1 (very positive) */ detectValence(text: string): number { if (!text || text.trim().length === 0) { return 0; } const features = this.extractEmotionalFeatures(text); return this.scoreValence(features); } /** * Detect arousal (emotional intensity/activation) * @param text - Text to analyze * @returns Arousal score from 0 (very calm) to 1 (very intense) */ detectArousal(text: string): number { if (!text || text.trim().length === 0) { return 0; } const features = this.extractEmotionalFeatures(text); return this.scoreArousal(features); } /** * Detect dominance (sense of control/power) * @param text - Text to analyze * @returns Dominance score from -1 (powerless) to +1 (powerful) */ detectDominance(text: string): number { if (!text || text.trim().length === 0) { return 0; } const features = this.extractEmotionalFeatures(text); return this.scoreDominance(features); } /** * Analyze text across all three circumplex dimensions * @param text - Text to analyze * @param options - Analysis options * @returns Complete circumplex state with confidence */ analyzeCircumplex(text: string, options?: EmotionAnalysisOptions): CircumplexState { const cacheResults = options?.cacheResults ?? true; // Check cache if (cacheResults && this.cache.has(text)) { const cached = this.cache.get(text); if (cached) { return cached; } } // Analyze dimensions const valence = this.detectValence(text); const arousal = this.detectArousal(text); const dominance = this.detectDominance(text); // Create state const state: CircumplexState = { valence, arousal, dominance, confidence: 0, // Will be calculated timestamp: new Date(), }; // Calculate confidence state.confidence = this.calculateConfidence(state); // Cache result if (cacheResults) { this.cacheResult(text, state); } return state; } /** * Calculate confidence in the emotion detection * @param state - Circumplex state to assess * @returns Confidence score from 0 to 1 */ calculateConfidence(state: CircumplexState): number { // Confidence is based on the strength of emotional signals // Neutral emotions (close to 0) have lower confidence // Strong emotions (far from 0) have higher confidence const valenceStrength = Math.abs(state.valence); const arousalStrength = state.arousal; // Already 0-1 const dominanceStrength = Math.abs(state.dominance); // Average the strengths const averageStrength = (valenceStrength + arousalStrength + dominanceStrength) / 3; // Map to confidence range (0.3 to 0.95) // Even weak signals have some confidence const confidence = 0.3 + averageStrength * 0.65; return Math.min(1, Math.max(0, confidence)); } /** * Extract emotional features from text * @param text - Text to analyze * @returns Extracted emotional features */ private extractEmotionalFeatures(text: string): EmotionalFeatures { // Count intensity markers BEFORE normalization (needs punctuation) const intensityMarkers = this.countIntensityMarkers(text); const normalized = this.normalizeText(text); const words = normalized.split(/\s+/).filter((w) => w.length > 0); const features: EmotionalFeatures = { positiveWords: [], negativeWords: [], highArousalWords: [], mediumArousalWords: [], lowArousalWords: [], highDominanceWords: [], lowDominanceWords: [], intensityMarkers, wordCount: words.length, }; // Classify words for (const word of words) { if (VALENCE_POSITIVE.has(word)) { features.positiveWords.push(word); } if (VALENCE_NEGATIVE.has(word)) { features.negativeWords.push(word); } if (AROUSAL_HIGH.has(word)) { features.highArousalWords.push(word); } if (AROUSAL_MEDIUM.has(word)) { if (features.mediumArousalWords) { features.mediumArousalWords.push(word); } } if (AROUSAL_LOW.has(word)) { features.lowArousalWords.push(word); } if (DOMINANCE_HIGH.has(word)) { features.highDominanceWords.push(word); } if (DOMINANCE_LOW.has(word)) { features.lowDominanceWords.push(word); } if (INTENSITY_AMPLIFIERS.has(word)) { features.intensityMarkers++; } } return features; } /** * Score valence from features * @param features - Extracted emotional features * @returns Valence score from -1 to +1 */ private scoreValence(features: EmotionalFeatures): number { const positiveCount = features.positiveWords.length; const negativeCount = features.negativeWords.length; const totalEmotional = positiveCount + negativeCount; if (totalEmotional === 0) { return 0; } // Calculate raw score const rawScore = (positiveCount - negativeCount) / totalEmotional; // Apply intensity amplification const amplification = 1 + features.intensityMarkers * 0.1; const amplifiedScore = rawScore * Math.min(amplification, 1.5); // Clamp to valid range return Math.max(-1, Math.min(1, amplifiedScore)); } /** * Score arousal from features * @param features - Extracted emotional features * @returns Arousal score from 0 to 1 */ private scoreArousal(features: EmotionalFeatures): number { const highCount = features.highArousalWords.length; const lowCount = features.lowArousalWords.length; const mediumCount = features.mediumArousalWords?.length ?? 0; const totalArousal = highCount + lowCount + mediumCount; // Base arousal from words let baseArousal = 0.15; // Low default for neutral/monotone text if (totalArousal > 0) { // Weighted average: high=1.0, medium=0.5, low=0.0 baseArousal = (highCount * 1.0 + mediumCount * 0.5 + lowCount * 0.0) / totalArousal; } // Intensity markers increase arousal const intensityBoost = Math.min(features.intensityMarkers * 0.15, 0.4); // Combine base and intensity const arousal = baseArousal + intensityBoost; // Clamp to valid range return Math.max(0, Math.min(1, arousal)); } /** * Score dominance from features * @param features - Extracted emotional features * @returns Dominance score from -1 to +1 */ private scoreDominance(features: EmotionalFeatures): number { const highCount = features.highDominanceWords.length; const lowCount = features.lowDominanceWords.length; const totalDominance = highCount + lowCount; if (totalDominance === 0) { return 0; } // Calculate raw score const rawScore = (highCount - lowCount) / totalDominance; // Clamp to valid range return Math.max(-1, Math.min(1, rawScore)); } /** * Normalize text for analysis * @param text - Text to normalize * @returns Normalized text */ private normalizeText(text: string): string { // Remove emojis and special characters but keep words return text .toLowerCase() .replace(/[\u{1F600}-\u{1F64F}]/gu, "") // Remove emoticons .replace(/[\u{1F300}-\u{1F5FF}]/gu, "") // Remove misc symbols .replace(/[\u{1F680}-\u{1F6FF}]/gu, "") // Remove transport symbols .replace(/[\u{2600}-\u{26FF}]/gu, "") // Remove misc symbols .replace(/[^\w\s]/g, " ") // Remove all non-word, non-space characters .replace(/\s+/g, " ") .trim(); } /** * Count intensity markers in text * @param text - Text to analyze * @returns Count of intensity markers */ private countIntensityMarkers(text: string): number { let count = 0; // Count exclamation marks count += (text.match(/!/g) ?? []).length; // Count question marks (can indicate intensity) count += (text.match(/\?/g) ?? []).length * 0.5; // Count capital letters (excluding first letter of sentences) const words = text.split(/\s+/); for (const word of words) { if (word.length > 1 && word === word.toUpperCase() && /[A-Z]/.test(word)) { count += 1; } } return count; } /** * Cache analysis result * @param text - Text key * @param state - State to cache */ private cacheResult(text: string, state: CircumplexState): void { // Implement LRU eviction if (this.cache.size >= this.maxCacheSize) { // Remove oldest entry const firstKey = this.cache.keys().next().value; if (firstKey !== undefined) { this.cache.delete(firstKey); } } this.cache.set(text, state); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/keyurgolani/ThoughtMcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server