@alanhelmick/memorable
Version:
An AI memory system enabling personalized, context-aware interactions through advanced memory management and emotional intelligence
351 lines (303 loc) • 10.2 kB
JavaScript
import { logger } from '../utils/logger.js';
import humeService from './humeService.js';
import videoStreamService from './videoStreamService.js';
import { getRedisClient } from '../config/redis.js';
import { emotionToVector, vectorToEmotion } from '../constants/emotions.js';
export class EmotionalContextService {
constructor() {
this.redis = null;
this.activeContexts = new Map();
this.emotionalBuffer = new Map();
this.bufferTimeout = 5000; // 5 seconds
this.customModelEnabled = false;
this.weights = {
evi: 0.5, // EVI's built-in emotional processing
video: 0.3, // Video facial analysis
voice: 0.2 // Voice prosody analysis
};
}
async initialize() {
this.redis = getRedisClient();
await this.setupEmotionalBuffers();
await this.loadCustomModel();
}
async loadCustomModel() {
try {
const hasCustomModel = await this.redis.exists('custom_model_config');
if (hasCustomModel) {
const config = JSON.parse(await this.redis.get('custom_model_config'));
this.customModelEnabled = config.enabled;
if (config.weights) {
this.weights = config.weights;
}
logger.info('Custom model configuration loaded');
}
} catch (error) {
logger.error('Failed to load custom model:', error);
}
}
async setupEmotionalBuffers() {
try {
const bufferKey = 'emotional_context_buffers';
const exists = await this.redis.exists(bufferKey);
if (!exists) {
await this.redis.hSet(bufferKey, {
active_sessions: '{}',
buffer_timeouts: '{}'
});
}
} catch (error) {
logger.error('Failed to setup emotional buffers:', error);
throw error;
}
}
async startContext(contextId, options = {}) {
const context = {
id: contextId,
startTime: Date.now(),
options: {
useVideo: options.useVideo ?? false,
useVoice: options.useVoice ?? true,
useEVI: options.useEVI ?? false,
customModel: options.customModel ?? this.customModelEnabled,
bufferSize: options.bufferSize ?? 5,
...options
},
emotionalState: {
current: 'neutral',
confidence: 1.0,
vector: emotionToVector('neutral'),
history: [],
sources: {}
}
};
this.activeContexts.set(contextId, context);
// Start video stream if enabled
if (context.options.useVideo) {
await videoStreamService.startStream(contextId, (emotionData) => {
this.handleVideoEmotion(contextId, emotionData);
}, {
resetStream: true,
faceConfig: {
// Configure face detection settings
minConfidence: 0.7,
returnPoints: true
}
});
}
// Initialize Hume stream for voice if not using EVI
if (context.options.useVoice && !context.options.useEVI) {
await humeService.startStream(contextId, {
models: { prosody: {} },
resetStream: true
});
}
logger.info(`Started emotional context ${contextId} with options:`, context.options);
return context;
}
async handleEVIEmotion(contextId, eviEmotion) {
const context = this.activeContexts.get(contextId);
if (!context) {
logger.warn(`Context ${contextId} not found for EVI emotion`);
return;
}
// Store EVI emotion in context sources
context.emotionalState.sources.evi = {
emotion: eviEmotion.emotion,
confidence: eviEmotion.confidence,
vector: eviEmotion.vector,
timestamp: Date.now()
};
await this.updateEmotionalState(contextId, {
emotion: eviEmotion.emotion,
confidence: eviEmotion.confidence,
vector: eviEmotion.vector,
source: 'evi',
timestamp: Date.now()
});
}
async handleVideoEmotion(contextId, videoEmotion) {
const context = this.activeContexts.get(contextId);
if (!context) {
logger.warn(`Context ${contextId} not found for video emotion`);
return;
}
if (videoEmotion.emotions.length > 0) {
const dominant = videoEmotion.emotions[0];
// Store video emotion in context sources
context.emotionalState.sources.video = {
emotion: dominant.name,
confidence: dominant.confidence,
vector: dominant.vector,
timestamp: videoEmotion.timestamp
};
await this.updateEmotionalState(contextId, {
emotion: dominant.name,
confidence: dominant.confidence,
vector: dominant.vector,
source: 'video',
timestamp: videoEmotion.timestamp
});
}
}
async handleVoiceEmotion(contextId, voiceData) {
const context = this.activeContexts.get(contextId);
if (!context) {
logger.warn(`Context ${contextId} not found for voice emotion`);
return;
}
try {
let emotions;
if (context.options.useEVI) {
// EVI handles voice emotion processing
return;
} else {
// Process voice through Hume
emotions = await humeService.processVoice(voiceData, contextId);
}
if (emotions.length > 0) {
const dominant = emotions[0];
// Store voice emotion in context sources
context.emotionalState.sources.voice = {
emotion: dominant.name,
confidence: dominant.confidence,
vector: dominant.vector,
timestamp: Date.now()
};
await this.updateEmotionalState(contextId, {
emotion: dominant.name,
confidence: dominant.confidence,
vector: dominant.vector,
source: 'voice',
timestamp: Date.now()
});
}
} catch (error) {
logger.error(`Error processing voice emotion for context ${contextId}:`, error);
}
}
async updateEmotionalState(contextId, emotionData) {
const context = this.activeContexts.get(contextId);
if (!context) return;
// Add to emotional buffer
if (!this.emotionalBuffer.has(contextId)) {
this.emotionalBuffer.set(contextId, []);
}
this.emotionalBuffer.get(contextId).push(emotionData);
// Process buffer if it reaches the size limit or after timeout
if (this.emotionalBuffer.get(contextId).length >= context.options.bufferSize) {
await this.processEmotionalBuffer(contextId);
} else {
// Set timeout to process buffer
setTimeout(async () => {
await this.processEmotionalBuffer(contextId);
}, this.bufferTimeout);
}
}
async processEmotionalBuffer(contextId) {
const buffer = this.emotionalBuffer.get(contextId);
if (!buffer || buffer.length === 0) return;
const context = this.activeContexts.get(contextId);
if (!context) return;
const combinedVector = new Array(buffer[0].vector.length).fill(0);
let totalWeight = 0;
// Process each source type separately
const sourceGroups = this.groupBySource(buffer);
for (const [source, emotions] of Object.entries(sourceGroups)) {
const weight = this.weights[source] * this.calculateSourceConfidence(emotions);
const sourceVector = this.combineSourceEmotions(emotions);
sourceVector.forEach((v, i) => {
combinedVector[i] += v * weight;
});
totalWeight += weight;
}
// Normalize vector
if (totalWeight > 0) {
combinedVector.forEach((_, i) => {
combinedVector[i] /= totalWeight;
});
}
// Update context state
context.emotionalState = {
current: vectorToEmotion(combinedVector),
confidence: totalWeight,
vector: combinedVector,
sources: context.emotionalState.sources,
history: [
...context.emotionalState.history,
{
timestamp: Date.now(),
emotions: buffer,
sources: { ...context.emotionalState.sources }
}
].slice(-100) // Keep last 100 emotional states
};
// Store in Redis
await this.redis.hSet(`emotional_context:${contextId}`, {
state: JSON.stringify(context.emotionalState),
lastUpdate: Date.now().toString()
});
// Clear buffer
this.emotionalBuffer.set(contextId, []);
}
groupBySource(buffer) {
return buffer.reduce((groups, emotion) => {
const source = emotion.source;
if (!groups[source]) {
groups[source] = [];
}
groups[source].push(emotion);
return groups;
}, {});
}
calculateSourceConfidence(emotions) {
return emotions.reduce((sum, e) => sum + e.confidence, 0) / emotions.length;
}
combineSourceEmotions(emotions) {
const vector = new Array(emotions[0].vector.length).fill(0);
emotions.forEach(emotion => {
emotion.vector.forEach((v, i) => {
vector[i] += v * emotion.confidence;
});
});
return vector;
}
async getEmotionalContext(contextId) {
const context = this.activeContexts.get(contextId);
if (!context) {
const storedContext = await this.redis.hGetAll(`emotional_context:${contextId}`);
if (storedContext.state) {
return JSON.parse(storedContext.state);
}
return null;
}
return context.emotionalState;
}
async stopContext(contextId) {
const context = this.activeContexts.get(contextId);
if (!context) return;
// Process any remaining emotions in buffer
await this.processEmotionalBuffer(contextId);
// Stop video stream if active
if (context.options.useVideo) {
await videoStreamService.stopStream(contextId);
}
// Stop Hume stream if using it for voice
if (context.options.useVoice && !context.options.useEVI) {
await humeService.stopStream(contextId);
}
// Clean up
this.activeContexts.delete(contextId);
this.emotionalBuffer.delete(contextId);
logger.info(`Stopped emotional context ${contextId}`);
}
async cleanup() {
// Stop all active contexts
for (const contextId of this.activeContexts.keys()) {
await this.stopContext(contextId);
}
}
}
// Create singleton instance
const emotionalContextService = new EmotionalContextService();
export default emotionalContextService;