diff --git a/src/modules/aichat/index.ts b/src/modules/aichat/index.ts index 7e08b150..1e0e7525 100644 --- a/src/modules/aichat/index.ts +++ b/src/modules/aichat/index.ts @@ -10,6 +10,7 @@ import serifs from '@/serifs.js'; import urlToBase64 from '@/utils/url2base64.js'; import urlToJson from '@/utils/url2json.js'; import { plain } from '@/utils/mfm.js'; +import PersonalizationEngine from '@/personalization/PersonalizationEngine.js'; type AiChat = { question: string; @@ -132,6 +133,7 @@ export default class extends Module { private randomTalkProbability: number = DEFAULTS.RANDOMTALK_PROBABILITY; private randomTalkIntervalMs: number = DEFAULTS.RANDOMTALK_INTERVAL_HOURS * HOURS_TO_MS; + private personalizationEngine!: PersonalizationEngine; // 型ガード関数 private isApiError(value: GeminiApiResponse): value is ApiErrorResponse { @@ -149,6 +151,10 @@ export default class extends Module { indices: ['postId', 'originalNoteId'], }); + // パーソナライゼーションエンジンを初期化 + this.personalizationEngine = new PersonalizationEngine(this.ai.db); + this.log('パーソナライゼーションエンジンが初期化されました'); + // Gemini全体が有効かチェック if (!config.gemini?.enabled) { this.log('Gemini機能が無効になっています'); @@ -267,7 +273,9 @@ export default class extends Module { @bindThis private async genTextByGemini( aiChat: AiChat, - files: Base64File[] + files: Base64File[], + userId?: string, + sessionId?: string ): Promise { this.log('Generate Text By Gemini...'); let parts: GeminiParts = []; @@ -294,7 +302,37 @@ export default class extends Module { '\n\nまた、現在日時は' + now + 'であり、これは回答の参考にし、絶対に時刻を聞かれるまで時刻情報は提供しないこと(なお、他の日時は無効とすること)。'; - if (aiChat.friendName != undefined) { + + // userIdが提供されている場合、パーソナライゼーションを統合 + let personalizedContext = ''; + if (userId && this.personalizationEngine) { + try { + const personalizationResult = await this.personalizationEngine.processMessage( + userId, + aiChat.question, + sessionId + ); + + if (personalizationResult.commandResult) { + // コマンドの場合、コマンド結果を直接返す + return personalizationResult.commandResult.message; + } + + if (personalizationResult.personalizedPrompt) { + // パーソナライズされたコンテキストを構築 + personalizedContext = this.personalizationEngine.buildPromptWithContext( + personalizationResult.personalizedPrompt, + aiChat.question + ); + } + } catch (error) { + this.log('パーソナライゼーションエラー: ' + error); + } + } + + if (personalizedContext) { + systemInstructionText = personalizedContext + '\n\n' + systemInstructionText; + } else if (aiChat.friendName != undefined) { systemInstructionText += 'なお、会話相手の名前は' + aiChat.friendName + 'とする。'; } @@ -1091,7 +1129,7 @@ export default class extends Module { }; const base64Files: Base64File[] = []; - const text = await this.genTextByGemini(aiChat, base64Files); + const text = await this.genTextByGemini(aiChat, base64Files, msg.userId, exist.postId); if (text) { this.ai.post({ text: text + ' #aichat' }); @@ -1187,7 +1225,7 @@ export default class extends Module { base64Files.push(...exist.quotedFiles); } - text = await this.genTextByGemini(aiChat, base64Files); + text = await this.genTextByGemini(aiChat, base64Files, msg.userId, exist.postId); if (this.isApiError(text)) { this.log('The result is invalid due to an HTTP error.'); @@ -1228,7 +1266,7 @@ export default class extends Module { ); } - msg.reply(serifs.aichat.post(responseText)).then((reply) => { + msg.reply(serifs.aichat.post(responseText)).then(async (reply) => { if (!exist.history) { exist.history = []; } @@ -1237,6 +1275,20 @@ export default class extends Module { if (exist.history.length > DEFAULTS.MAX_HISTORY_LENGTH) { exist.history.shift(); } + + // パーソナライゼーションシステムに応答を保存 + if (this.personalizationEngine && msg.userId) { + try { + await this.personalizationEngine.processResponse( + msg.userId, + exist.postId, + question, + text as string + ); + } catch (error) { + this.log('パーソナライゼーション応答の保存エラー: ' + error); + } + } const newRecord: AiChatHist = { postId: reply.id, diff --git a/src/personalization/ContextEngine.ts b/src/personalization/ContextEngine.ts new file mode 100644 index 00000000..6feb6806 --- /dev/null +++ b/src/personalization/ContextEngine.ts @@ -0,0 +1,479 @@ +import { bindThis } from '@/decorators.js'; +import { + Memory, + ShortTermMemory, + UserProfile, + Entity, + EntityType, + PersonalizedPrompt, + ResponseContext +} from './types.js'; +import HybridMemorySystem from './HybridMemorySystem.js'; +import UserProfileManager from './UserProfileManager.js'; + +/** + * コンテキストエンジン + * パーソナライズされた応答のためのコンテキストをインテリジェントに取得・管理 + */ +export default class ContextEngine { + private readonly MAX_CONTEXT_MEMORIES = 10; + private readonly CONTEXT_WINDOW_SIZE = 4000; // 文字数 + private readonly RELEVANCE_THRESHOLD = 0.3; + + constructor( + private memorySystem: HybridMemorySystem, + private profileManager: UserProfileManager + ) {} + + /** + * 完全な応答コンテキストを構築 + */ + @bindThis + public async buildResponseContext( + userId: string, + message: string, + sessionId: string + ): Promise { + // Get user profile + const userProfile = await this.profileManager.getOrCreateProfile(userId); + + // Get short-term memory + const shortTermContext = await this.memorySystem.getOrCreateSession(userId, sessionId); + + // Extract entities from message + const entities = this.extractEntities(message); + + // Update short-term memory with current message + await this.memorySystem.updateShortTermMemory( + sessionId, + { + content: message, + timestamp: Date.now(), + relevance: 1.0, + type: 'user_input' + }, + { + entities, + topic: this.inferTopic(message, entities), + intent: this.inferIntent(message) + } + ); + + // Retrieve relevant long-term memories + const longTermContext = await this.memorySystem.retrieveRelevantMemories( + userId, + message, + this.MAX_CONTEXT_MEMORIES + ); + + // Build response context + return { + userId, + message, + shortTermContext, + longTermContext, + userProfile, + privacySettings: { + userId, + allowInference: true, + allowLongTermStorage: true, + dataRetentionDays: 365, + sensitiveTopics: [], + autoDeletePatterns: [] + } + }; + } + + /** + * Generate personalized prompt for LLM + */ + @bindThis + public async generatePersonalizedPrompt( + context: ResponseContext + ): Promise { + const { userProfile, shortTermContext, longTermContext, message } = context; + + // Build system prompt + const systemPrompt = this.buildSystemPrompt(userProfile); + + // Build user context + const userContext = this.buildUserContext(userProfile, shortTermContext); + + // Select most relevant memories + const relevantMemories = this.selectRelevantMemories( + longTermContext, + message, + shortTermContext + ); + + // Get relationship context + const relationshipContext = this.profileManager.getRelationshipContext(userProfile); + + // Get style guidance + const styleGuidance = this.profileManager.getStyleGuidance(userProfile); + + return { + systemPrompt, + userContext, + relevantMemories, + relationshipContext, + styleGuidance + }; + } + + /** + * Extract information from AI response for memory storage + */ + @bindThis + public async processResponse( + userId: string, + sessionId: string, + aiResponse: string, + userMessage: string + ): Promise { + // Update short-term memory with AI response + await this.memorySystem.updateShortTermMemory( + sessionId, + { + content: aiResponse, + timestamp: Date.now(), + relevance: 0.8, + type: 'ai_response' + } + ); + + // Extract any new information about the user + const extractedInfo = this.extractUserInfo(userMessage); + + if (extractedInfo.explicit) { + await this.profileManager.addExplicitInfo(userId, extractedInfo.explicit); + } + + if (extractedInfo.implicit) { + await this.profileManager.inferImplicitInfo(userId, { + message: userMessage, + context: aiResponse, + entities: this.extractEntities(userMessage).map(e => e.text) + }); + } + + // Determine if this interaction should be consolidated to long-term memory + const shouldConsolidate = await this.shouldConsolidateMemory(sessionId); + + if (shouldConsolidate) { + await this.memorySystem.consolidateMemories(sessionId, userId); + } + } + + /** + * Calculate context relevance score + */ + @bindThis + public calculateRelevance( + memory: Memory, + currentContext: ShortTermMemory, + query: string + ): number { + let score = 0; + + // Recency factor + const daysSinceMemory = (Date.now() - memory.timestamp) / (1000 * 60 * 60 * 24); + const recencyScore = Math.exp(-daysSinceMemory / 30); // Exponential decay over 30 days + score += recencyScore * 0.2; + + // Topic similarity + const memoryTopics = memory.metadata.tags; + const currentTopic = currentContext.context.topic; + if (memoryTopics.includes(currentTopic)) { + score += 0.3; + } + + // Entity overlap + const memoryEntities = new Set(memory.metadata.entities); + const currentEntities = new Set(currentContext.context.entities.map(e => e.text)); + const entityOverlap = [...memoryEntities].filter(e => currentEntities.has(e)).length; + score += (entityOverlap / Math.max(memoryEntities.size, 1)) * 0.3; + + // Keyword matching + const keywords = this.extractKeywords(query); + const keywordMatches = keywords.filter(k => + memory.content.toLowerCase().includes(k.toLowerCase()) + ).length; + score += (keywordMatches / Math.max(keywords.length, 1)) * 0.2; + + // Apply memory importance and decay + score *= memory.importance * memory.decay; + + return Math.min(1, score); + } + + // Private helper methods + + private buildSystemPrompt(profile: UserProfile): string { + const traits = profile.implicit.personality; + const expertise = profile.implicit.expertise; + + let prompt = "You are a helpful AI assistant with deep personalization capabilities. "; + + // Add personality understanding + if (traits.openness > 0.7) { + prompt += "The user appreciates creative and novel ideas. "; + } + if (traits.conscientiousness > 0.7) { + prompt += "The user values detailed, well-organized responses. "; + } + + // Add expertise context + if (expertise.length > 0) { + prompt += `The user has expertise in: ${expertise.join(', ')}. `; + } + + // Add communication preferences + if (profile.explicit.preferences.responseLength) { + prompt += `Prefer ${profile.explicit.preferences.responseLength} responses. `; + } + + return prompt; + } + + private buildUserContext( + profile: UserProfile, + shortTerm: ShortTermMemory + ): string { + const parts: string[] = []; + + // Add user identification + if (profile.explicit.name) { + parts.push(`User's name: ${profile.explicit.name}`); + } + + // Add current conversation context + if (shortTerm.context.topic) { + parts.push(`Current topic: ${shortTerm.context.topic}`); + } + + // Add recent context from working memory + const recentInputs = shortTerm.workingMemory + .filter(item => item.type === 'user_input') + .slice(-3) + .map(item => item.content); + + if (recentInputs.length > 0) { + parts.push(`Recent conversation: ${recentInputs.join(' | ')}`); + } + + // Add user interests and goals + if (profile.explicit.interests.length > 0) { + parts.push(`Interests: ${profile.explicit.interests.join(', ')}`); + } + + if (profile.explicit.goals.length > 0) { + parts.push(`Goals: ${profile.explicit.goals.join(', ')}`); + } + + return parts.join('\n'); + } + + private selectRelevantMemories( + memories: Memory[], + query: string, + shortTerm: ShortTermMemory + ): Memory[] { + // Calculate relevance scores + const scoredMemories = memories.map(memory => ({ + memory, + score: this.calculateRelevance(memory, shortTerm, query) + })); + + // Sort by relevance + scoredMemories.sort((a, b) => b.score - a.score); + + // Select memories that fit within context window + const selected: Memory[] = []; + let totalLength = 0; + + for (const { memory, score } of scoredMemories) { + if (score < this.RELEVANCE_THRESHOLD) break; + + const memoryLength = memory.content.length; + if (totalLength + memoryLength > this.CONTEXT_WINDOW_SIZE) break; + + selected.push(memory); + totalLength += memoryLength; + } + + return selected; + } + + private extractEntities(text: string): Entity[] { + const entities: Entity[] = []; + + // Simple regex-based entity extraction + // In production, use NLP library or API + + // Extract names (capitalized words) + const namePattern = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/g; + const names = text.match(namePattern) || []; + names.forEach(name => { + entities.push({ + text: name, + type: EntityType.PERSON, + confidence: 0.7 + }); + }); + + // Extract dates + const datePattern = /\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b|\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* \d{1,2}(?:, \d{4})?\b/gi; + const dates = text.match(datePattern) || []; + dates.forEach(date => { + entities.push({ + text: date, + type: EntityType.DATE, + confidence: 0.9 + }); + }); + + // Extract organizations (simple heuristic) + const orgPattern = /\b(?:Inc|Corp|LLC|Ltd|Company|Corporation)\b/gi; + const orgs = text.match(new RegExp(`\\b[A-Z][\\w\\s]+(?:${orgPattern.source})`, 'gi')) || []; + orgs.forEach(org => { + entities.push({ + text: org, + type: EntityType.ORGANIZATION, + confidence: 0.6 + }); + }); + + return entities; + } + + private inferTopic(message: string, entities: Entity[]): string { + // Simple topic inference + // In production, use topic modeling or classification + + const keywords = this.extractKeywords(message); + + // Check for common topics + const topicPatterns = { + 'technology': /\b(computer|software|code|programming|tech|AI|machine learning)\b/i, + 'work': /\b(job|work|career|office|meeting|project|deadline)\b/i, + 'personal': /\b(family|friend|home|life|feel|emotion)\b/i, + 'learning': /\b(learn|study|course|education|teach|understand)\b/i, + 'entertainment': /\b(movie|music|game|book|show|watch|play)\b/i, + 'health': /\b(health|doctor|medicine|exercise|diet|sleep)\b/i, + 'travel': /\b(travel|trip|vacation|visit|fly|hotel)\b/i + }; + + for (const [topic, pattern] of Object.entries(topicPatterns)) { + if (pattern.test(message)) { + return topic; + } + } + + // Default to first keyword if no pattern matches + return keywords[0] || 'general'; + } + + private inferIntent(message: string): string { + // Simple intent classification + // In production, use intent classification model + + const intents = { + 'question': /^(what|who|where|when|why|how|is|are|can|could|would|should)\b/i, + 'request': /\b(please|could you|can you|would you|help|need|want)\b/i, + 'statement': /\b(I am|I have|I think|I believe|I feel)\b/i, + 'greeting': /^(hi|hello|hey|good morning|good afternoon|good evening)\b/i, + 'farewell': /\b(bye|goodbye|see you|talk later|good night)\b/i, + 'appreciation': /\b(thank|thanks|appreciate|grateful)\b/i, + 'complaint': /\b(problem|issue|wrong|broken|doesn't work|frustrated)\b/i + }; + + for (const [intent, pattern] of Object.entries(intents)) { + if (pattern.test(message)) { + return intent; + } + } + + return 'statement'; + } + + private extractKeywords(text: string): string[] { + const stopWords = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', + 'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been' + ]); + + return text + .toLowerCase() + .split(/\W+/) + .filter(word => word.length > 2 && !stopWords.has(word)) + .slice(0, 10); + } + + private extractUserInfo(userMessage: string): { + explicit?: Partial; + implicit?: boolean; + } { + const info: { + explicit?: Partial; + implicit?: boolean; + } = {}; + + // Extract explicit information from user message + const nameMatch = userMessage.match(/(?:my name is|i'm|i am) ([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/i); + if (nameMatch && nameMatch[1]) { + info.explicit = { name: nameMatch[1].trim() }; + } + + const ageMatch = userMessage.match(/(?:i'm|i am) (\d+) years old/i); + if (ageMatch && ageMatch[1]) { + const age = parseInt(ageMatch[1], 10); + if (!isNaN(age) && age > 0 && age < 150) { + info.explicit = { ...info.explicit, age }; + } + } + + const interestMatch = userMessage.match(/(?:i like|i love|i enjoy|interested in) ([^.!?]+)/i); + if (interestMatch && interestMatch[1]) { + const interest = interestMatch[1].trim(); + if (interest.length > 0) { + info.explicit = { + ...info.explicit, + interests: [interest] + }; + } + } + + // Mark for implicit analysis if patterns detected + if (/\b(think|feel|believe|prefer|usually|always|never)\b/i.test(userMessage)) { + info.implicit = true; + } + + return info; + } + + private async shouldConsolidateMemory(sessionId: string): Promise { + // Consolidate based on various factors + // This is a simplified version + + const session = await this.memorySystem.getOrCreateSession('', sessionId); + + // Consolidate if session has enough interactions + if (session.workingMemory.length >= 8) { + return true; + } + + // Consolidate if significant time has passed + const sessionDuration = Date.now() - session.startTime; + if (sessionDuration > 20 * 60 * 1000) { // 20 minutes + return true; + } + + // Consolidate if topic changed significantly + if (session.context.previousTopics.length >= 3) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/personalization/HybridMemorySystem.ts b/src/personalization/HybridMemorySystem.ts new file mode 100644 index 00000000..a28e6a94 --- /dev/null +++ b/src/personalization/HybridMemorySystem.ts @@ -0,0 +1,612 @@ +import { bindThis } from '@/decorators.js'; +import loki from 'lokijs'; +import { v4 as uuid } from 'uuid'; +import { + Memory, + MemoryType, + MemoryMetadata, + MemorySource, + ShortTermMemory, + ConversationContext, + WorkingMemoryItem, + Entity, + VectorMemory, + MemoryQuery, + PersonalizationError, + PersonalizationErrorCode +} from './types.js'; + +/** + * ハイブリッド記憶システム + * インテリジェントな検索と忘却機能を備えた短期および長期記憶を管理 + */ +export default class HybridMemorySystem { + private memories: loki.Collection; + private shortTermMemories: loki.Collection; + private vectorMemories: loki.Collection; + + // 記憶設定 + private readonly MAX_WORKING_MEMORY_SIZE = 10; + private readonly MAX_SHORT_TERM_SESSIONS = 100; + private readonly MEMORY_DECAY_RATE = 0.95; // 月あたり + private readonly IMPORTANCE_THRESHOLD = 0.3; + private readonly SESSION_TIMEOUT = 30 * 60 * 1000; // 30分 + + constructor(private db: loki) { + this.memories = this.db.getCollection('memories') || + this.db.addCollection('memories', { + indices: ['userId', 'type', 'timestamp', 'importance'] + }); + + this.shortTermMemories = this.db.getCollection('shortTermMemories') || + this.db.addCollection('shortTermMemories', { + indices: ['userId', 'sessionId', 'lastUpdate'] + }); + + this.vectorMemories = this.db.getCollection('vectorMemories') || + this.db.addCollection('vectorMemories', { + indices: ['userId', 'timestamp'] + }); + } + + /** + * 新しい記憶を保存 + */ + @bindThis + public async storeMemory( + userId: string, + content: string, + type: MemoryType, + metadata: Partial, + importance: number = 0.5 + ): Promise { + const memory: Memory = { + id: uuid(), + userId, + timestamp: Date.now(), + type, + content, + importance: this.normalizeImportance(importance), + accessCount: 0, + lastAccessed: Date.now(), + decay: 1.0, + metadata: { + entities: metadata.entities || [], + source: metadata.source || MemorySource.DIRECT_STATEMENT, + confidence: metadata.confidence || 0.8, + tags: metadata.tags || [], + ...metadata + } + }; + + this.memories.insert(memory); + + // Store vector embedding if applicable + if (type === MemoryType.EPISODIC || type === MemoryType.SEMANTIC) { + await this.createVectorMemory(memory); + } + + return memory; + } + + /** + * クエリに基づいて記憶を取得 + */ + @bindThis + public async queryMemories(query: MemoryQuery): Promise { + let results = this.memories.chain() + .find({ userId: query.userId }) + .simplesort('timestamp', true); + + // Apply filters + if (query.filters) { + if (query.filters.type) { + results = results.find({ type: { $in: query.filters.type } }); + } + + if (query.filters.dateRange) { + results = results.find({ + timestamp: { + $gte: query.filters.dateRange.start, + $lte: query.filters.dateRange.end + } + }); + } + + if (query.filters.importance) { + results = results.find({ + importance: { + $gte: query.filters.importance.min, + $lte: query.filters.importance.max + } + }); + } + + if (query.filters.tags && query.filters.tags.length > 0) { + results = results.where((memory: Memory) => + query.filters!.tags!.some(tag => memory.metadata.tags.includes(tag)) + ); + } + } + + // Apply pagination + if (query.offset) { + results = results.offset(query.offset); + } + + if (query.limit) { + results = results.limit(query.limit); + } + + const memories = results.data(); + + // Update access counts + memories.forEach(memory => { + memory.accessCount++; + memory.lastAccessed = Date.now(); + this.memories.update(memory); + }); + + return memories; + } + + /** + * Get or create short-term memory session + */ + @bindThis + public async getOrCreateSession( + userId: string, + sessionId?: string + ): Promise { + // Clean up old sessions first + await this.cleanupOldSessions(); + + if (sessionId) { + const existing = this.shortTermMemories.findOne({ sessionId, userId }); + if (existing && Date.now() - existing.lastUpdate < this.SESSION_TIMEOUT) { + return existing; + } + } + + // Create new session + const newSession: ShortTermMemory = { + sessionId: sessionId || uuid(), + userId, + startTime: Date.now(), + lastUpdate: Date.now(), + context: { + topic: '', + mood: 'neutral', + intent: '', + entities: [], + previousTopics: [] + }, + workingMemory: [], + attention: { + primary: '', + secondary: [], + weights: {} + } + }; + + this.shortTermMemories.insert(newSession); + return newSession; + } + + /** + * Update short-term memory with new interaction + */ + @bindThis + public async updateShortTermMemory( + sessionId: string, + item: WorkingMemoryItem, + context?: Partial + ): Promise { + const session = this.shortTermMemories.findOne({ sessionId }); + if (!session) { + throw new PersonalizationError( + 'Session not found', + PersonalizationErrorCode.MEMORY_NOT_FOUND + ); + } + + // Add to working memory (FIFO if at capacity) + session.workingMemory.push(item); + if (session.workingMemory.length > this.MAX_WORKING_MEMORY_SIZE) { + session.workingMemory.shift(); + } + + // Update context if provided + if (context) { + Object.assign(session.context, context); + + // Track topic transitions + if (context.topic && context.topic !== session.context.topic) { + session.context.previousTopics.push(session.context.topic); + if (session.context.previousTopics.length > 5) { + session.context.previousTopics.shift(); + } + } + } + + // Update attention based on relevance + this.updateAttention(session, item); + + session.lastUpdate = Date.now(); + this.shortTermMemories.update(session); + + return session; + } + + /** + * Convert important short-term memories to long-term + */ + @bindThis + public async consolidateMemories( + sessionId: string, + userId: string + ): Promise { + const session = this.shortTermMemories.findOne({ sessionId }); + if (!session) { + return []; + } + + const consolidatedMemories: Memory[] = []; + + // Analyze working memory for important information + const importantItems = session.workingMemory.filter( + item => item.relevance > this.IMPORTANCE_THRESHOLD + ); + + // Group related items and create episodic memories + if (importantItems.length > 0) { + const episodicContent = this.summarizeWorkingMemory(importantItems); + const episodicMemory = await this.storeMemory( + userId, + episodicContent, + MemoryType.EPISODIC, + { + entities: session.context.entities.map(e => e.text), + context: session.context.topic, + source: MemorySource.OBSERVED, + confidence: 0.9, + tags: ['conversation', session.context.topic] + }, + this.calculateEpisodicImportance(session) + ); + consolidatedMemories.push(episodicMemory); + } + + // Extract semantic facts + const facts = this.extractSemanticFacts(session); + for (const fact of facts) { + const semanticMemory = await this.storeMemory( + userId, + fact.content, + MemoryType.SEMANTIC, + { + entities: fact.entities, + source: fact.source, + confidence: fact.confidence, + tags: fact.tags + }, + fact.importance + ); + consolidatedMemories.push(semanticMemory); + } + + return consolidatedMemories; + } + + /** + * Retrieve relevant memories for current context + */ + @bindThis + public async retrieveRelevantMemories( + userId: string, + context: string, + limit: number = 5 + ): Promise { + // For now, use simple keyword matching + // In production, this would use vector similarity search + + const keywords = this.extractKeywords(context); + + const memories = this.memories.chain() + .find({ userId }) + .where((memory: Memory) => { + // Check content relevance + const contentScore = keywords.reduce((score, keyword) => { + return score + (memory.content.toLowerCase().includes(keyword) ? 1 : 0); + }, 0) / Math.max(1, keywords.length); + + // Check tag relevance + const tagScore = memory.metadata.tags.reduce((score, tag) => { + return score + (keywords.includes(tag.toLowerCase()) ? 1 : 0); + }, 0) / Math.max(memory.metadata.tags.length, 1); + + // Check entity relevance + const entityScore = memory.metadata.entities.reduce((score, entity) => { + return score + (keywords.includes(entity.toLowerCase()) ? 1 : 0); + }, 0) / Math.max(memory.metadata.entities.length, 1); + + // Combined relevance score + const relevance = (contentScore * 0.5 + tagScore * 0.3 + entityScore * 0.2) * + memory.importance * memory.decay; + + return relevance > 0.1; + }) + .simplesort('importance', true) + .limit(limit) + .data(); + + return memories; + } + + /** + * Apply forgetting mechanism + */ + @bindThis + public async applyForgetting(userId: string): Promise { + const memories = this.memories.find({ userId }); + const now = Date.now(); + + memories.forEach(memory => { + // Calculate time-based decay + const monthsSinceCreation = (now - memory.timestamp) / (1000 * 60 * 60 * 24 * 30); + const timeDecay = Math.pow(this.MEMORY_DECAY_RATE, monthsSinceCreation); + + // Access-based boost + const accessBoost = Math.min(1.5, 1 + memory.accessCount * 0.1); + + // Update decay factor + memory.decay = Math.min(1, timeDecay * accessBoost); + + // Remove memories below threshold + if (memory.decay * memory.importance < 0.1) { + this.memories.remove(memory); + + // Also remove associated vector memory + const vectorMem = this.vectorMemories.findOne({ memoryId: memory.id }); + if (vectorMem) { + this.vectorMemories.remove(vectorMem); + } + } else { + this.memories.update(memory); + } + }); + } + + /** + * Delete specific memories + */ + @bindThis + public async deleteMemories(userId: string, memoryIds: string[]): Promise { + const memories = this.memories.find({ + userId, + id: { $in: memoryIds } + }); + + memories.forEach(memory => { + this.memories.remove(memory); + + // Remove associated vector memory + const vectorMem = this.vectorMemories.findOne({ memoryId: memory.id }); + if (vectorMem) { + this.vectorMemories.remove(vectorMem); + } + }); + } + + /** + * Get memory statistics for a user + */ + @bindThis + public async getMemoryStats(userId: string): Promise<{ + total: number; + byType: Record; + averageImportance: number; + oldestMemory: number | null; + newestMemory: number | null; + }> { + const memories = this.memories.find({ userId }); + + const stats = { + total: memories.length, + byType: { + [MemoryType.EPISODIC]: 0, + [MemoryType.SEMANTIC]: 0, + [MemoryType.PROCEDURAL]: 0, + [MemoryType.WORKING]: 0 + }, + averageImportance: 0, + oldestMemory: null as number | null, + newestMemory: null as number | null + }; + + if (memories.length === 0) { + return stats; + } + + let totalImportance = 0; + let oldest = Date.now(); + let newest = 0; + + memories.forEach(memory => { + stats.byType[memory.type]++; + totalImportance += memory.importance; + + if (memory.timestamp < oldest) oldest = memory.timestamp; + if (memory.timestamp > newest) newest = memory.timestamp; + }); + + stats.averageImportance = totalImportance / memories.length; + stats.oldestMemory = oldest; + stats.newestMemory = newest; + + return stats; + } + + // Private helper methods + + private normalizeImportance(importance: number): number { + return Math.max(0, Math.min(1, importance)); + } + + private async createVectorMemory(memory: Memory): Promise { + // In production, this would call an embedding API + // For now, create a placeholder + const vectorMemory: VectorMemory = { + id: uuid(), + userId: memory.userId, + content: memory.content, + embedding: this.generateMockEmbedding(memory.content), + metadata: { + memoryId: memory.id, + type: memory.type, + importance: memory.importance, + tags: memory.metadata.tags + }, + timestamp: memory.timestamp + }; + + this.vectorMemories.insert(vectorMemory); + } + + private generateMockEmbedding(content: string): number[] { + // Mock embedding generation + // In production, use OpenAI embeddings or similar + const embedding = new Array(384).fill(0); + for (let i = 0; i < content.length; i++) { + embedding[i % 384] += content.charCodeAt(i) / 1000; + } + return embedding.map(v => v / content.length); + } + + private updateAttention(session: ShortTermMemory, item: WorkingMemoryItem): void { + // Extract main topic from item + const topics = this.extractKeywords(item.content); + + if (topics.length > 0) { + // Update primary attention + session.attention.primary = topics[0]; + + // Update secondary attention + session.attention.secondary = topics.slice(1, 4); + + // Update weights + topics.forEach(topic => { + session.attention.weights[topic] = + (session.attention.weights[topic] || 0) + item.relevance; + }); + } + } + + private summarizeWorkingMemory(items: WorkingMemoryItem[]): string { + // Simple concatenation for now + // In production, use LLM to summarize + const userInputs = items + .filter(item => item.type === 'user_input') + .map(item => item.content) + .join(' '); + + return `Conversation summary: ${userInputs.substring(0, 500)}...`; + } + + private calculateEpisodicImportance(session: ShortTermMemory): number { + // Calculate importance based on various factors + const factors = { + duration: Math.min(1, (session.lastUpdate - session.startTime) / (1000 * 60 * 30)), // 30 min = 1.0 + interactions: Math.min(1, session.workingMemory.length / 20), + entities: Math.min(1, session.context.entities.length / 10), + topicChanges: Math.min(1, session.context.previousTopics.length / 5) + }; + + return Object.values(factors).reduce((sum, val) => sum + val, 0) / Object.keys(factors).length; + } + + private extractSemanticFacts(session: ShortTermMemory): Array<{ + content: string; + entities: string[]; + source: MemorySource; + confidence: number; + tags: string[]; + importance: number; + }> { + const facts: Array<{ + content: string; + entities: string[]; + source: MemorySource; + confidence: number; + tags: string[]; + importance: number; + }> = []; + + // Extract facts from user inputs + session.workingMemory + .filter(item => item.type === 'user_input') + .forEach(item => { + // Simple pattern matching for facts + const patterns = [ + /I (?:am|work as|do) (.+)/i, + /My (.+) is (.+)/i, + /I (?:like|love|enjoy|prefer) (.+)/i, + /I (?:have|own) (.+)/i + ]; + + patterns.forEach(pattern => { + const match = item.content.match(pattern); + if (match) { + facts.push({ + content: match[0], + entities: session.context.entities.map(e => e.text), + source: MemorySource.DIRECT_STATEMENT, + confidence: 0.9, + tags: [session.context.topic, 'fact'], + importance: 0.7 + }); + } + }); + }); + + return facts; + } + + private extractKeywords(text: string): string[] { + // Simple keyword extraction + // In production, use NLP library + const stopWords = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', + 'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been', + 'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', + 'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those' + ]); + + return text + .toLowerCase() + .split(/\W+/) + .filter(word => word.length > 2 && !stopWords.has(word)) + .slice(0, 10); + } + + private async cleanupOldSessions(): Promise { + const cutoff = Date.now() - this.SESSION_TIMEOUT; + const oldSessions = this.shortTermMemories.find({ + lastUpdate: { $lt: cutoff } + }); + + oldSessions.forEach(session => { + this.shortTermMemories.remove(session); + }); + + // Keep only recent sessions + const allSessions = this.shortTermMemories.chain() + .simplesort('lastUpdate', true) + .offset(this.MAX_SHORT_TERM_SESSIONS) + .data(); + + allSessions.forEach(session => { + this.shortTermMemories.remove(session); + }); + } +} \ No newline at end of file diff --git a/src/personalization/PersonalizationEngine.ts b/src/personalization/PersonalizationEngine.ts new file mode 100644 index 00000000..9bcc967c --- /dev/null +++ b/src/personalization/PersonalizationEngine.ts @@ -0,0 +1,394 @@ +import { bindThis } from '@/decorators.js'; +import loki from 'lokijs'; +import { + ResponseContext, + PersonalizedPrompt, + MemoryType, + MemorySource, + WorkingMemoryItem +} from './types.js'; +import UserProfileManager from './UserProfileManager.js'; +import HybridMemorySystem from './HybridMemorySystem.js'; +import ContextEngine from './ContextEngine.js'; +import UserMemoryInterface from './UserMemoryInterface.js'; + +/** + * パーソナライゼーションエンジン + * すべてのパーソナライゼーション機能の中央オーケストレーター + */ +export default class PersonalizationEngine { + private profileManager: UserProfileManager; + private memorySystem: HybridMemorySystem; + private contextEngine: ContextEngine; + private userInterface: UserMemoryInterface; + + // バックグラウンドタスクの間隔 + private forgettingInterval: NodeJS.Timeout | null = null; + private consolidationInterval: NodeJS.Timeout | null = null; + + // 設定 + private readonly FORGETTING_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24時間 + private readonly CONSOLIDATION_CHECK_MS = 30 * 60 * 1000; // 30分 + + constructor(private db: loki) { + // コンポーネントを初期化 + this.profileManager = new UserProfileManager(db); + this.memorySystem = new HybridMemorySystem(db); + this.contextEngine = new ContextEngine(this.memorySystem, this.profileManager); + this.userInterface = new UserMemoryInterface(this.memorySystem, this.profileManager); + + // バックグラウンドタスクを開始 + this.startBackgroundTasks(); + } + + /** + * パーソナライゼーションを使用して受信メッセージを処理 + */ + @bindThis + public async processMessage( + userId: string, + message: string, + sessionId?: string + ): Promise<{ + response?: string; + personalizedPrompt?: PersonalizedPrompt; + commandResult?: { success: boolean; message: string; data?: any }; + }> { + // メッセージがコマンドかどうかをチェック + const commandResult = await this.userInterface.processCommand(userId, message); + if (commandResult.success || message.match(/^(help|ヘルプ|h|\?|memories?|記憶|思い出|forget|忘れる|忘却|update_info|情報更新|update|profile|プロフィール|prof|export_data|データエクスポート|export|delete_all_data|全データ削除|delete_all)/i)) { + return { commandResult }; + } + + // Create or get session + const activeSessionId = sessionId || await this.createSession(userId); + + // Build response context + const context = await this.contextEngine.buildResponseContext( + userId, + message, + activeSessionId + ); + + // Generate personalized prompt + const personalizedPrompt = await this.contextEngine.generatePersonalizedPrompt(context); + + // Record interaction + await this.profileManager.recordInteraction(userId); + + // Infer implicit information from message + await this.profileManager.inferImplicitInfo(userId, { + message, + entities: context.shortTermContext.context.entities.map(e => e.text) + }); + + return { personalizedPrompt }; + } + + /** + * Store AI response and extract information + */ + @bindThis + public async processResponse( + userId: string, + sessionId: string, + userMessage: string, + aiResponse: string + ): Promise { + // Process response for information extraction + await this.contextEngine.processResponse( + userId, + sessionId, + aiResponse, + userMessage + ); + + // Check for important information to store + await this.extractAndStoreImportantInfo(userId, userMessage, aiResponse); + } + + /** + * Create a new session + */ + @bindThis + public async createSession(userId: string): Promise { + const session = await this.memorySystem.getOrCreateSession(userId); + return session.sessionId; + } + + /** + * Get personalization statistics + */ + @bindThis + public async getStats(userId: string): Promise<{ + profile: any; + memoryStats: any; + sessionCount: number; + }> { + const profile = await this.profileManager.getOrCreateProfile(userId); + const memoryStats = await this.memorySystem.getMemoryStats(userId); + + // Count active sessions (simplified) + const sessionCount = 1; // In production, track active sessions properly + + return { + profile: { + relationshipLevel: profile.relationship.level, + totalInteractions: profile.relationship.totalInteractions, + dataQuality: profile.meta.dataQuality, + trustScore: profile.relationship.trustScore + }, + memoryStats, + sessionCount + }; + } + + /** + * Shutdown and cleanup + */ + @bindThis + public shutdown(): void { + // Stop background tasks + if (this.forgettingInterval) { + clearInterval(this.forgettingInterval); + this.forgettingInterval = null; + } + + if (this.consolidationInterval) { + clearInterval(this.consolidationInterval); + this.consolidationInterval = null; + } + } + + // Private methods + + private startBackgroundTasks(): void { + // Forgetting mechanism - runs daily + this.forgettingInterval = setInterval(async () => { + await this.runForgettingMechanism(); + }, this.FORGETTING_INTERVAL_MS); + + // Memory consolidation check - runs every 30 minutes + this.consolidationInterval = setInterval(async () => { + await this.checkMemoryConsolidation(); + }, this.CONSOLIDATION_CHECK_MS); + } + + private async runForgettingMechanism(): Promise { + try { + // Get all users (in production, this would be more efficient) + const profiles = this.db.getCollection('userProfiles').find({}); + + for (const profile of profiles) { + // Apply forgetting to memories + await this.memorySystem.applyForgetting(profile.userId); + + // Apply forgetting to profile + await this.profileManager.applyForgetting(profile.userId); + } + } catch (error) { + console.error('Error in forgetting mechanism:', error); + } + } + + private async checkMemoryConsolidation(): Promise { + try { + // Get active sessions + const sessions = this.db.getCollection('shortTermMemories').find({}); + + for (const session of sessions) { + // Check if session should be consolidated + const shouldConsolidate = + session.workingMemory.length >= 10 || + Date.now() - session.lastUpdate > 20 * 60 * 1000; // 20 minutes idle + + if (shouldConsolidate) { + await this.memorySystem.consolidateMemories( + session.sessionId, + session.userId + ); + } + } + } catch (error) { + console.error('Error in memory consolidation:', error); + } + } + + private async extractAndStoreImportantInfo( + userId: string, + userMessage: string, + aiResponse: string + ): Promise { + // Extract facts from conversation + const facts = this.extractFacts(userMessage); + + for (const fact of facts) { + await this.memorySystem.storeMemory( + userId, + fact.content, + MemoryType.SEMANTIC, + { + entities: fact.entities, + source: MemorySource.DIRECT_STATEMENT, + confidence: fact.confidence, + tags: fact.tags + }, + fact.importance + ); + } + + // Store important episodic moments + if (this.isImportantMoment(userMessage, aiResponse)) { + const episodicContent = `User: ${userMessage}\nAI: ${aiResponse.substring(0, 200)}...`; + + await this.memorySystem.storeMemory( + userId, + episodicContent, + MemoryType.EPISODIC, + { + entities: [], + source: MemorySource.OBSERVED, + confidence: 0.8, + tags: ['conversation', 'important'] + }, + 0.7 + ); + } + } + + private extractFacts(message: string): Array<{ + content: string; + entities: string[]; + confidence: number; + tags: string[]; + importance: number; + }> { + const facts: Array<{ + content: string; + entities: string[]; + confidence: number; + tags: string[]; + importance: number; + }> = []; + + // Patterns for extracting facts + const patterns = [ + { + regex: /(?:I am|I'm) ([^.!?]+)/i, + tag: 'identity', + importance: 0.8 + }, + { + regex: /(?:I work|My job|I do) ([^.!?]+)/i, + tag: 'occupation', + importance: 0.7 + }, + { + regex: /(?:I like|I love|I enjoy) ([^.!?]+)/i, + tag: 'preference', + importance: 0.6 + }, + { + regex: /(?:I have|I own) ([^.!?]+)/i, + tag: 'possession', + importance: 0.5 + }, + { + regex: /(?:My goal|I want to|I plan to) ([^.!?]+)/i, + tag: 'goal', + importance: 0.8 + } + ]; + + for (const pattern of patterns) { + const match = message.match(pattern.regex); + if (match && match[0] && match[1]) { + facts.push({ + content: match[0], + entities: this.extractEntities(match[1]), + confidence: 0.9, + tags: [pattern.tag, 'user_fact'], + importance: pattern.importance + }); + } + } + + return facts; + } + + private extractEntities(text: string): string[] { + // Simple entity extraction + const entities: string[] = []; + + // Extract capitalized words as potential entities + const capitalizedWords = text.match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/g) || []; + entities.push(...capitalizedWords); + + return [...new Set(entities)]; + } + + private isImportantMoment(userMessage: string, aiResponse: string): boolean { + // Heuristics for determining important moments + const importantIndicators = [ + /thank you|thanks/i, + /that helps|very helpful/i, + /amazing|wonderful|excellent/i, + /problem solved|fixed it/i, + /learned something/i, + /milestone|achievement/i, + /important|significant/i + ]; + + const combinedText = userMessage + ' ' + aiResponse; + + return importantIndicators.some(pattern => pattern.test(combinedText)); + } + + /** + * Build prompt with personalization context + */ + @bindThis + public buildPromptWithContext( + personalizedPrompt: PersonalizedPrompt, + userMessage: string + ): string { + const parts: string[] = []; + + // System context + parts.push(`[System Context]`); + parts.push(personalizedPrompt.systemPrompt); + parts.push(''); + + // Relationship context + parts.push(`[Relationship Context]`); + parts.push(personalizedPrompt.relationshipContext); + parts.push(''); + + // Style guidance + parts.push(`[Communication Style]`); + parts.push(personalizedPrompt.styleGuidance); + parts.push(''); + + // User context + if (personalizedPrompt.userContext) { + parts.push(`[User Context]`); + parts.push(personalizedPrompt.userContext); + parts.push(''); + } + + // Relevant memories + if (personalizedPrompt.relevantMemories.length > 0) { + parts.push(`[Relevant Past Information]`); + personalizedPrompt.relevantMemories.forEach((memory, index) => { + parts.push(`${index + 1}. ${memory.content}`); + }); + parts.push(''); + } + + // Current message + parts.push(`[Current User Message]`); + parts.push(userMessage); + + return parts.join('\n'); + } +} \ No newline at end of file diff --git a/src/personalization/UserMemoryInterface.ts b/src/personalization/UserMemoryInterface.ts new file mode 100644 index 00000000..20767f60 --- /dev/null +++ b/src/personalization/UserMemoryInterface.ts @@ -0,0 +1,509 @@ +import { bindThis } from '@/decorators.js'; +import { + Memory, + MemoryType, + UserProfile, + MemoryQuery, + CommandResult, + PersonalizationError, + PersonalizationErrorCode +} from './types.js'; +import HybridMemorySystem from './HybridMemorySystem.js'; +import UserProfileManager from './UserProfileManager.js'; + +/** + * ユーザー記憶インターフェース + * ユーザーが個人データと記憶を制御できる機能を提供 + */ +export default class UserMemoryInterface { + // コマンドパターン(複数言語対応) + private readonly COMMANDS = { + SHOW_MEMORIES: /^(memories?|記憶|思い出)\s*(.*)$/i, + FORGET: /^(forget|忘れる|忘却)\s+(.+)$/i, + UPDATE_INFO: /^(update_info|情報更新|update)\s+(.+)$/i, + SHOW_PROFILE: /^(profile|プロフィール|prof)$/i, + EXPORT_DATA: /^(export_data|データエクスポート|export)$/i, + DELETE_ALL: /^(delete_all_data|全データ削除|delete_all)$/i, + HELP: /^(help|ヘルプ|h|\?)$/i + }; + + constructor( + private memorySystem: HybridMemorySystem, + private profileManager: UserProfileManager + ) {} + + /** + * Process user command + */ + @bindThis + public async processCommand( + userId: string, + command: string + ): Promise { + // Validate input + if (!userId || !command) { + return { + success: false, + message: '無効な入力です。' + }; + } + + // Check for memory-related commands + for (const [cmdName, pattern] of Object.entries(this.COMMANDS)) { + const match = command.match(pattern); + if (match) { + try { + switch (cmdName) { + case 'SHOW_MEMORIES': + return await this.showMemories(userId, match[2] || ''); + + case 'FORGET': + // Ensure there's an argument for forget command + if (!match[2] || !match[2].trim()) { + return { + success: false, + message: '忘れる対象を指定してください。' + }; + } + return await this.forgetMemory(userId, match[2].trim()); + + case 'UPDATE_INFO': + // Ensure there's an argument for update command + if (!match[2] || !match[2].trim()) { + return { + success: false, + message: '更新する情報を指定してください。' + }; + } + return await this.updateInfo(userId, match[2].trim()); + + case 'SHOW_PROFILE': + return await this.showProfile(userId); + + case 'EXPORT_DATA': + return await this.exportUserData(userId); + + case 'DELETE_ALL': + return await this.deleteAllData(userId); + + case 'HELP': + return this.showHelp(); + } + } catch (error) { + console.error(`Error processing command ${cmdName}:`, error); + return { + success: false, + message: 'コマンドの処理中にエラーが発生しました。' + }; + } + } + } + + return { + success: false, + message: 'コマンドが認識されませんでした。help または ヘルプ で利用可能なコマンドを確認してください。' + }; + } + + /** + * Show user's memories + */ + @bindThis + private async showMemories( + userId: string, + filterString: string + ): Promise<{ success: boolean; message: string; data?: any }> { + try { + // Parse filters from string + const filters = this.parseMemoryFilters(filterString); + + const query: MemoryQuery = { + userId, + filters, + limit: 10 + }; + + const memories = await this.memorySystem.queryMemories(query); + const stats = await this.memorySystem.getMemoryStats(userId); + + if (memories.length === 0) { + return { + success: true, + message: '条件に一致する記憶が見つかりませんでした。' + }; + } + + // Format memories for display + const formattedMemories = memories.map((memory, index) => + this.formatMemory(memory, index + 1) + ).join('\n\n'); + + const message = `📚 **あなたの記憶** (全${stats.total}件中${memories.length}件)\n\n${formattedMemories}\n\n` + + `📊 **統計**\n` + + `- 総記憶数: ${stats.total}\n` + + `- エピソード記憶: ${stats.byType.episodic}\n` + + `- 意味記憶: ${stats.byType.semantic}\n` + + `- 平均重要度: ${(stats.averageImportance * 100).toFixed(1)}%`; + + return { + success: true, + message, + data: { memories, stats } + }; + } catch (error) { + return { + success: false, + message: `Error retrieving memories: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Forget specific memory + */ + @bindThis + private async forgetMemory( + userId: string, + searchTerm: string + ): Promise<{ success: boolean; message: string }> { + try { + // Search for memories containing the term + const memories = await this.memorySystem.queryMemories({ + userId, + filters: { search: searchTerm }, + limit: 50 + }); + + if (memories.length === 0) { + return { + success: false, + message: `"${searchTerm}"を含む記憶が見つかりませんでした` + }; + } + + // If multiple memories found, ask for confirmation + if (memories.length > 1) { + const preview = memories.slice(0, 3).map((m, i) => + `${i + 1}. ${m.content.substring(0, 50)}...` + ).join('\n'); + + return { + success: false, + message: `"${searchTerm}"を含む記憶が${memories.length}件見つかりました:\n${preview}\n\n` + + `より具体的に指定するか、記憶IDを使用してください。` + }; + } + + // Delete the memory + await this.memorySystem.deleteMemories(userId, [memories[0].id]); + + return { + success: true, + message: `✅ 記憶を忘れました: "${memories[0].content.substring(0, 100)}..."` + }; + } catch (error) { + return { + success: false, + message: `Error forgetting memory: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Update user information + */ + @bindThis + private async updateInfo( + userId: string, + updateString: string + ): Promise<{ success: boolean; message: string }> { + try { + // Parse update string + const updates = this.parseUpdateString(updateString); + + if (Object.keys(updates).length === 0) { + return { + success: false, + message: '無効な更新形式です。使用例: update_info name=太郎 interests=プログラミング,音楽' + }; + } + + // Update profile + await this.profileManager.addExplicitInfo(userId, updates); + + const updatedFields = Object.keys(updates).join(', '); + return { + success: true, + message: `✅ 更新しました: ${updatedFields}` + }; + } catch (error) { + return { + success: false, + message: `Error updating information: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Show user profile + */ + @bindThis + private async showProfile(userId: string): Promise<{ success: boolean; message: string; data?: any }> { + try { + const profile = await this.profileManager.getOrCreateProfile(userId); + + const message = this.formatProfile(profile); + + return { + success: true, + message, + data: profile + }; + } catch (error) { + return { + success: false, + message: `Error retrieving profile: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Export all user data + */ + @bindThis + private async exportUserData(userId: string): Promise<{ success: boolean; message: string; data?: any }> { + try { + // Gather all user data + const profile = await this.profileManager.exportProfile(userId); + const memories = await this.memorySystem.queryMemories({ userId }); + const stats = await this.memorySystem.getMemoryStats(userId); + + const exportData = { + exportDate: new Date().toISOString(), + profile, + memories, + statistics: stats + }; + + return { + success: true, + message: '📦 データのエクスポート準備が完了しました。このJSONデータを保存できます。', + data: exportData + }; + } catch (error) { + return { + success: false, + message: `Error exporting data: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Delete all user data + */ + @bindThis + private async deleteAllData(userId: string): Promise<{ success: boolean; message: string }> { + try { + // Delete all memories + const memories = await this.memorySystem.queryMemories({ userId }); + const memoryIds = memories.map(m => m.id); + await this.memorySystem.deleteMemories(userId, memoryIds); + + // Delete profile + await this.profileManager.deleteProfile(userId); + + return { + success: true, + message: '🗑️ すべての個人データが完全に削除されました。' + }; + } catch (error) { + return { + success: false, + message: `Error deleting data: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Show help message + */ + private showHelp(): { success: boolean; message: string } { + const helpMessage = ` +📖 **記憶管理コマンド** + +**データの確認:** +• \`memories\` / \`記憶\` / \`思い出\` [フィルター] - 保存された記憶を表示 + - 例: \`memories\`, \`記憶 最近\`, \`memories recent\` +• \`profile\` / \`プロフィール\` / \`prof\` - ユーザープロフィールを表示 +• \`export_data\` / \`データエクスポート\` / \`export\` - すべてのデータをJSON形式でエクスポート + +**データの管理:** +• \`forget\` / \`忘れる\` / \`忘却\` <検索語> - 特定の記憶を削除 + - 例: \`forget password\`, \`忘れる プロジェクトの締切\` +• \`update_info\` / \`情報更新\` / \`update\` <フィールド=値> - 情報を更新 + - 例: \`update_info name=太郎 interests=音楽,アート\` +• \`delete_all_data\` / \`全データ削除\` / \`delete_all\` - すべてのデータを完全に削除 + +**情報更新で使用可能なフィールド:** +• name/名前, age/年齢, location/場所, occupation/職業 +• interests/興味 (カンマ区切り) +• goals/目標 (カンマ区切り) +• preferences/設定 (キー=値のペア) + +**プライバシーに関するお知らせ:** +あなたのデータは安全に保存され、完全に管理できます。これらのコマンドを使用して、私が覚えている内容を管理してください。 + `.trim(); + + return { + success: true, + message: helpMessage + }; + } + + // Helper methods + + private formatMemory(memory: Memory, index: number): string { + const date = new Date(memory.timestamp).toLocaleDateString(); + const importance = `${(memory.importance * 100).toFixed(0)}%`; + const type = memory.type.charAt(0).toUpperCase() + memory.type.slice(1); + + return `**${index}.** [${type}] ${date} (重要度: ${importance})\n` + + ` ${memory.content.substring(0, 150)}${memory.content.length > 150 ? '...' : ''}\n` + + ` タグ: ${memory.metadata.tags.join(', ') || 'なし'}`; + } + + private formatProfile(profile: UserProfile): string { + const { explicit, implicit, relationship, meta } = profile; + + let message = '👤 **あなたのプロフィール**\n\n'; + + // 明示的情報 + message += '**基本情報:**\n'; + if (explicit.name) message += `• 名前: ${explicit.name}\n`; + if (explicit.age) message += `• 年齢: ${explicit.age}\n`; + if (explicit.location) message += `• 場所: ${explicit.location}\n`; + if (explicit.occupation) message += `• 職業: ${explicit.occupation}\n`; + if (explicit.interests.length > 0) message += `• 興味: ${explicit.interests.join(', ')}\n`; + if (explicit.goals.length > 0) message += `• 目標: ${explicit.goals.join(', ')}\n`; + + // 関係性情報 + message += '\n**私たちの関係:**\n'; + message += `• レベル: ${this.formatRelationshipLevel(relationship.level)}\n`; + message += `• 総対話数: ${relationship.totalInteractions}\n`; + message += `• 信頼スコア: ${(relationship.trustScore * 100).toFixed(0)}%\n`; + message += `• 初回対話: ${new Date(relationship.firstInteraction).toLocaleDateString()}\n`; + + // 推論された情報(高い確信度の場合) + if (implicit.communicationStyle) { + message += '\n**コミュニケーションスタイル:**\n'; + message += `• 好みのスタイル: ${implicit.communicationStyle}\n`; + } + + if (implicit.expertise.length > 0) { + message += `• 専門分野: ${implicit.expertise.join(', ')}\n`; + } + + // データ品質 + message += `\n**プロフィール品質:** ${this.formatDataQuality(meta.dataQuality)}`; + + return message; + } + + private formatRelationshipLevel(level: string): string { + const levelMap: Record = { + 'new_user': '🆕 新規ユーザー', + 'acquaintance': '👋 知り合い', + 'familiar': '🤝 親しい', + 'friend': '😊 友人', + 'collaborator': '🌟 親密な協力者' + }; + return levelMap[level] || level; + } + + private formatDataQuality(quality: string): string { + const qualityMap: Record = { + 'low': '📊 低 (改善のために情報を追加してください)', + 'medium': '📊📊 中 (良い基盤)', + 'high': '📊📊📊 高 (包括的なプロフィール)' + }; + return qualityMap[quality] || quality; + } + + private parseMemoryFilters(filterString: string): MemoryQuery['filters'] { + const filters: MemoryQuery['filters'] = {}; + + if (!filterString) return filters; + + // 一般的なフィルターキーワードを解析 + if (filterString.includes('最近')) { + filters.dateRange = { + start: Date.now() - 7 * 24 * 60 * 60 * 1000, // 過去7日間 + end: Date.now() + }; + } + + if (filterString.includes('重要')) { + filters.importance = { min: 0.7, max: 1.0 }; + } + + if (filterString.includes('episodic')) { + filters.type = [MemoryType.EPISODIC]; + } + + if (filterString.includes('semantic')) { + filters.type = [MemoryType.SEMANTIC]; + } + + // If no specific filters, treat as search term + if (Object.keys(filters).length === 0 && filterString.trim()) { + filters.search = filterString.trim(); + } + + return filters; + } + + private parseUpdateString(updateString: string): Partial { + const updates: Partial = {}; + + // Parse key=value pairs + const pairs = updateString.split(/\s+/); + + for (const pair of pairs) { + const [key, value] = pair.split('='); + if (!key || !value) continue; + + switch (key.toLowerCase()) { + case '名前': + case 'name': + updates.name = value; + break; + case '年齢': + case 'age': + updates.age = parseInt(value); + break; + case '場所': + case 'location': + updates.location = value; + break; + case '職業': + case 'occupation': + updates.occupation = value; + break; + case '興味': + case 'interests': + updates.interests = value.split(',').map(i => i.trim()); + break; + case '目標': + case 'goals': + updates.goals = value.split(',').map(g => g.trim()); + break; + default: + // 設定に保存 + if (!updates.preferences) updates.preferences = {}; + updates.preferences[key] = value; + } + } + + return updates; + } +} \ No newline at end of file diff --git a/src/personalization/UserProfileManager.ts b/src/personalization/UserProfileManager.ts new file mode 100644 index 00000000..309da695 --- /dev/null +++ b/src/personalization/UserProfileManager.ts @@ -0,0 +1,524 @@ +import { bindThis } from '@/decorators.js'; +import loki from 'lokijs'; +import { + UserProfile, + RelationshipLevel, + CommunicationStyle, + PersonalityTraits, + BehaviorPattern, + DataQuality, + PersonalizationError, + PersonalizationErrorCode +} from './types.js'; + +/** + * ユーザープロファイルマネージャー + * 明示的および暗黙的な情報を含む動的なユーザープロファイルを管理 + */ +export default class UserProfileManager { + private profiles: loki.Collection; + private readonly PROFILE_VERSION = 1; + + // 関係性レベルの閾値 + private readonly RELATIONSHIP_THRESHOLDS = { + ACQUAINTANCE: 5, + FAMILIAR: 20, + FRIEND: 50, + COLLABORATOR: 100 + }; + + // 情報タイプ別の減衰率 + private readonly DECAY_RATES = { + interests: 0.95, // 遅い減衰 + patterns: 0.90, // 中程度の減衰 + mood: 0.70 // 速い減衰 + }; + + constructor(private db: loki) { + this.profiles = this.db.getCollection('userProfiles') || + this.db.addCollection('userProfiles', { + indices: ['userId'], + unique: ['userId'] + }); + } + + /** + * ユーザープロファイルを取得または作成 + */ + @bindThis + public async getOrCreateProfile(userId: string): Promise { + let profile = this.profiles.findOne({ userId }); + + if (!profile) { + profile = this.createDefaultProfile(userId); + this.profiles.insert(profile); + } + + return profile; + } + + /** + * 新しい情報でユーザープロファイルを更新 + */ + @bindThis + public async updateProfile( + userId: string, + updates: Partial + ): Promise { + const profile = await this.getOrCreateProfile(userId); + + // Deep merge updates + this.mergeProfileUpdates(profile, updates); + + // Update metadata + profile.updatedAt = Date.now(); + profile.meta.version = this.PROFILE_VERSION; + + // Update relationship level based on interactions + this.updateRelationshipLevel(profile); + + // Update data quality assessment + this.assessDataQuality(profile); + + this.profiles.update(profile); + return profile; + } + + /** + * ユーザーが提供した明示的な情報を追加 + */ + @bindThis + public async addExplicitInfo( + userId: string, + info: Partial + ): Promise { + const profile = await this.getOrCreateProfile(userId); + + // Merge explicit information + Object.assign(profile.explicit, info); + + // Update arrays without duplicates + if (info.interests) { + profile.explicit.interests = [...new Set([ + ...profile.explicit.interests, + ...info.interests + ])]; + } + + if (info.goals) { + profile.explicit.goals = [...new Set([ + ...profile.explicit.goals, + ...info.goals + ])]; + } + + profile.updatedAt = Date.now(); + this.profiles.update(profile); + + return profile; + } + + /** + * ユーザーの対話から暗黙的な情報を推論 + */ + @bindThis + public async inferImplicitInfo( + userId: string, + interaction: { + message: string; + context?: string; + sentiment?: number; + entities?: string[]; + } + ): Promise { + const profile = await this.getOrCreateProfile(userId); + + // Infer communication style + const style = this.inferCommunicationStyle(interaction.message); + if (style && (!profile.implicit.communicationStyle || + this.shouldUpdateInference(profile.meta.lastAnalyzed))) { + profile.implicit.communicationStyle = style; + } + + // Extract and update expertise + const expertise = this.extractExpertise(interaction.message, interaction.entities); + if (expertise.length > 0) { + profile.implicit.expertise = this.mergeWithConfidence( + profile.implicit.expertise, + expertise + ); + } + + // Update personality traits based on interaction + this.updatePersonalityTraits(profile, interaction); + + // Track behavior patterns + this.trackBehaviorPattern(profile, interaction); + + profile.meta.lastAnalyzed = Date.now(); + this.profiles.update(profile); + + return profile; + } + + /** + * 対話統計を更新 + */ + @bindThis + public async recordInteraction( + userId: string, + quality: number = 1.0 + ): Promise { + try { + const profile = await this.getOrCreateProfile(userId); + + profile.relationship.totalInteractions++; + profile.relationship.lastInteraction = Date.now(); + + // Update trust score based on interaction quality + profile.relationship.trustScore = this.updateTrustScore( + profile.relationship.trustScore, + quality, + profile.relationship.totalInteractions + ); + + // Check for relationship level upgrade + this.updateRelationshipLevel(profile); + + this.profiles.update(profile); + return profile; + } catch (error) { + console.error('Error recording interaction:', error); + throw new PersonalizationError( + 'Failed to record interaction', + PersonalizationErrorCode.PROFILE_UPDATE_FAILED + ); + } + } + + /** + * 応答生成のための関係性コンテキストを取得 + */ + @bindThis + public getRelationshipContext(profile: UserProfile): string { + const level = profile.relationship.level; + const interactions = profile.relationship.totalInteractions; + + switch (level) { + case RelationshipLevel.NEW_USER: + return "新しいユーザーです。歓迎し、親切に対応し、物事を明確に説明してください。"; + + case RelationshipLevel.ACQUAINTANCE: + return `${interactions}回の対話がありました。友好的でありながら、まだ説明的に対応してください。`; + + case RelationshipLevel.FAMILIAR: + return `${interactions}回の対話がある親しいユーザーです。よりカジュアルに、過去の会話を参照できます。`; + + case RelationshipLevel.FRIEND: + return `${interactions}回の対話がある友人です。温かく、個人的に、共有した経験を参照してください。`; + + case RelationshipLevel.COLLABORATOR: + return `${interactions}回の対話がある親密な協力者です。高度にパーソナライズし、ニーズを予測し、深い共有コンテキストを構築してください。`; + + default: + return "自然で親切に対話してください。"; + } + } + + /** + * コミュニケーションスタイルのガイダンスを取得 + */ + @bindThis + public getStyleGuidance(profile: UserProfile): string { + const style = profile.implicit.communicationStyle; + + if (!style) { + return "ユーザーの好みに基づいてコミュニケーションスタイルを適応させてください。"; + } + + switch (style) { + case CommunicationStyle.FORMAL: + return "フォーマルな言葉遣い、完全な文章、プロフェッショナルなトーンを使用してください。"; + + case CommunicationStyle.CASUAL: + return "カジュアルで友好的な言葉遣い、省略形や口語表現を使用してください。"; + + case CommunicationStyle.TECHNICAL: + return "正確な技術用語を使用し、詳細と仕様を含めてください。"; + + case CommunicationStyle.CREATIVE: + return "創造的で表現豊かな言葉遣い、比喩や鮮明な描写を使用してください。"; + + case CommunicationStyle.ANALYTICAL: + return "論理的で構造化されたコミュニケーション、明確な理由付けと証拠を使用してください。"; + + default: + return "明確で自然なコミュニケーションを使用してください。"; + } + } + + /** + * Apply forgetting mechanism to profile data + */ + @bindThis + public async applyForgetting(userId: string): Promise { + const profile = await this.getOrCreateProfile(userId); + const now = Date.now(); + const daysSinceLastInteraction = (now - profile.relationship.lastInteraction) / (1000 * 60 * 60 * 24); + + // Don't forget anything for active users + if (daysSinceLastInteraction < 7) { + return profile; + } + + // Apply decay to behavior patterns + profile.implicit.patterns = profile.implicit.patterns + .map(pattern => ({ + ...pattern, + confidence: pattern.confidence * Math.pow(this.DECAY_RATES.patterns, daysSinceLastInteraction / 30) + })) + .filter(pattern => pattern.confidence > 0.1); // Remove low confidence patterns + + // Decay personality trait confidence + const decayFactor = Math.pow(0.98, daysSinceLastInteraction / 30); + Object.keys(profile.implicit.personality).forEach(trait => { + profile.implicit.personality[trait as keyof PersonalityTraits] *= decayFactor; + }); + + this.profiles.update(profile); + return profile; + } + + /** + * Delete user profile + */ + @bindThis + public async deleteProfile(userId: string): Promise { + const profile = this.profiles.findOne({ userId }); + if (profile) { + this.profiles.remove(profile); + } + } + + /** + * Export user profile data + */ + @bindThis + public async exportProfile(userId: string): Promise { + return this.profiles.findOne({ userId }); + } + + // Private helper methods + + private createDefaultProfile(userId: string): UserProfile { + const now = Date.now(); + return { + userId, + createdAt: now, + updatedAt: now, + explicit: { + interests: [], + goals: [], + preferences: {} + }, + implicit: { + expertise: [], + values: [], + personality: { + openness: 0.5, + conscientiousness: 0.5, + extraversion: 0.5, + agreeableness: 0.5, + neuroticism: 0.5 + }, + patterns: [] + }, + relationship: { + level: RelationshipLevel.NEW_USER, + firstInteraction: now, + totalInteractions: 0, + lastInteraction: now, + trustScore: 0.5 + }, + meta: { + version: this.PROFILE_VERSION, + lastAnalyzed: now, + dataQuality: DataQuality.LOW + } + }; + } + + private mergeProfileUpdates( + profile: UserProfile, + updates: Partial + ): void { + // Deep merge implementation + if (updates.explicit) { + Object.assign(profile.explicit, updates.explicit); + } + if (updates.implicit) { + Object.assign(profile.implicit, updates.implicit); + } + if (updates.relationship) { + Object.assign(profile.relationship, updates.relationship); + } + } + + private updateRelationshipLevel(profile: UserProfile): void { + const interactions = profile.relationship.totalInteractions; + + if (interactions >= this.RELATIONSHIP_THRESHOLDS.COLLABORATOR) { + profile.relationship.level = RelationshipLevel.COLLABORATOR; + } else if (interactions >= this.RELATIONSHIP_THRESHOLDS.FRIEND) { + profile.relationship.level = RelationshipLevel.FRIEND; + } else if (interactions >= this.RELATIONSHIP_THRESHOLDS.FAMILIAR) { + profile.relationship.level = RelationshipLevel.FAMILIAR; + } else if (interactions >= this.RELATIONSHIP_THRESHOLDS.ACQUAINTANCE) { + profile.relationship.level = RelationshipLevel.ACQUAINTANCE; + } + } + + private assessDataQuality(profile: UserProfile): void { + let qualityScore = 0; + + // Check explicit data completeness + if (profile.explicit.name) qualityScore += 0.2; + if (profile.explicit.interests.length > 0) qualityScore += 0.1; + if (profile.explicit.goals.length > 0) qualityScore += 0.1; + + // Check implicit data richness + if (profile.implicit.communicationStyle) qualityScore += 0.2; + if (profile.implicit.expertise.length > 0) qualityScore += 0.2; + if (profile.implicit.patterns.length > 0) qualityScore += 0.2; + + if (qualityScore >= 0.7) { + profile.meta.dataQuality = DataQuality.HIGH; + } else if (qualityScore >= 0.4) { + profile.meta.dataQuality = DataQuality.MEDIUM; + } else { + profile.meta.dataQuality = DataQuality.LOW; + } + } + + private inferCommunicationStyle(message: string): CommunicationStyle | null { + // Simple heuristics for communication style inference + const formalIndicators = /\b(please|kindly|would you|could you|sincerely|regards)\b/i; + const casualIndicators = /\b(hey|hi|yeah|gonna|wanna|lol|btw)\b/i; + const technicalIndicators = /\b(API|database|algorithm|function|implementation|architecture)\b/i; + const creativeIndicators = /\b(imagine|create|design|beautiful|inspire|dream)\b/i; + const analyticalIndicators = /\b(analyze|compare|evaluate|consider|therefore|however)\b/i; + + if (formalIndicators.test(message)) return CommunicationStyle.FORMAL; + if (casualIndicators.test(message)) return CommunicationStyle.CASUAL; + if (technicalIndicators.test(message)) return CommunicationStyle.TECHNICAL; + if (creativeIndicators.test(message)) return CommunicationStyle.CREATIVE; + if (analyticalIndicators.test(message)) return CommunicationStyle.ANALYTICAL; + + return null; + } + + private extractExpertise(message: string, entities?: string[]): string[] { + const expertise: string[] = []; + + // Technical expertise patterns + const techPatterns = { + programming: /\b(code|programming|developer|software|debug)\b/i, + data: /\b(data|analysis|statistics|machine learning|AI)\b/i, + design: /\b(design|UX|UI|graphics|visual)\b/i, + business: /\b(business|management|strategy|marketing|sales)\b/i + }; + + Object.entries(techPatterns).forEach(([field, pattern]) => { + if (pattern.test(message)) { + expertise.push(field); + } + }); + + return expertise; + } + + private updatePersonalityTraits( + profile: UserProfile, + interaction: { message: string; sentiment?: number } + ): void { + // Simple personality inference based on interaction patterns + const messageLength = interaction.message.length; + const questionCount = (interaction.message.match(/\?/g) || []).length; + const exclamationCount = (interaction.message.match(/!/g) || []).length; + + // Update traits with small increments + const delta = 0.02; + + // Openness: questions indicate curiosity + if (questionCount > 0) { + profile.implicit.personality.openness = Math.min(1, + profile.implicit.personality.openness + delta * questionCount + ); + } + + // Extraversion: longer messages and exclamations + if (messageLength > 100 || exclamationCount > 0) { + profile.implicit.personality.extraversion = Math.min(1, + profile.implicit.personality.extraversion + delta + ); + } + + // Agreeableness: positive sentiment + if (interaction.sentiment && interaction.sentiment > 0) { + profile.implicit.personality.agreeableness = Math.min(1, + profile.implicit.personality.agreeableness + delta * interaction.sentiment + ); + } + } + + private trackBehaviorPattern( + profile: UserProfile, + interaction: { message: string; context?: string } + ): void { + // Track common patterns + const patterns: { [key: string]: boolean } = { + 'morning_greeting': /^(good morning|morning|gm)/i.test(interaction.message), + 'technical_questions': /\b(how|what|why|explain)\b.*\b(work|function|implement)\b/i.test(interaction.message), + 'creative_requests': /\b(create|make|design|build|write)\b/i.test(interaction.message), + 'learning_oriented': /\b(learn|understand|know|teach|explain)\b/i.test(interaction.message) + }; + + Object.entries(patterns).forEach(([type, detected]) => { + if (detected) { + const existing = profile.implicit.patterns.find(p => p.type === type); + if (existing) { + existing.frequency++; + existing.lastOccurrence = Date.now(); + existing.confidence = Math.min(1, existing.confidence + 0.1); + } else { + profile.implicit.patterns.push({ + type, + frequency: 1, + lastOccurrence: Date.now(), + confidence: 0.3 + }); + } + } + }); + } + + private updateTrustScore( + currentScore: number, + interactionQuality: number, + totalInteractions: number + ): number { + // Weighted average with more weight on recent interactions + const weight = Math.min(0.1, 1 / Math.sqrt(totalInteractions)); + return currentScore * (1 - weight) + interactionQuality * weight; + } + + private shouldUpdateInference(lastAnalyzed: number): boolean { + // Update inferences if more than 24 hours have passed + return Date.now() - lastAnalyzed > 24 * 60 * 60 * 1000; + } + + private mergeWithConfidence(existing: string[], newItems: string[]): string[] { + // Simple merge for now - could be enhanced with confidence scores + return [...new Set([...existing, ...newItems])]; + } +} \ No newline at end of file diff --git a/src/personalization/index.ts b/src/personalization/index.ts new file mode 100644 index 00000000..643d0923 --- /dev/null +++ b/src/personalization/index.ts @@ -0,0 +1,13 @@ +/** + * Personalization System + * Export all components for easy import + */ + +export { default as PersonalizationEngine } from './PersonalizationEngine.js'; +export { default as UserProfileManager } from './UserProfileManager.js'; +export { default as HybridMemorySystem } from './HybridMemorySystem.js'; +export { default as ContextEngine } from './ContextEngine.js'; +export { default as UserMemoryInterface } from './UserMemoryInterface.js'; + +// Export types +export * from './types.js'; \ No newline at end of file diff --git a/src/personalization/types.ts b/src/personalization/types.ts new file mode 100644 index 00000000..d61822bb --- /dev/null +++ b/src/personalization/types.ts @@ -0,0 +1,336 @@ +/** + * AIチャットパーソナライゼーションシステムの型定義 + */ + +// ユーザープロファイル型 +export interface UserProfile { + userId: string; + createdAt: number; + updatedAt: number; + + // 明示的情報 + explicit: { + name?: string; + age?: number; + location?: string; + occupation?: string; + interests: string[]; + goals: string[]; + preferences: Record; + }; + + // 推論された情報 + implicit: { + communicationStyle?: CommunicationStyle; + expertise: string[]; + values: string[]; + personality: PersonalityTraits; + patterns: BehaviorPattern[]; + }; + + // 関係性の追跡 + relationship: { + level: RelationshipLevel; + firstInteraction: number; + totalInteractions: number; + lastInteraction: number; + trustScore: number; // 0-1 + }; + + // メタ情報 + meta: { + version: number; + lastAnalyzed: number; + dataQuality: DataQuality; + }; +} + +export enum RelationshipLevel { + NEW_USER = 'new_user', + ACQUAINTANCE = 'acquaintance', + FAMILIAR = 'familiar', + FRIEND = 'friend', + COLLABORATOR = 'collaborator' +} + +export enum CommunicationStyle { + FORMAL = 'formal', + CASUAL = 'casual', + TECHNICAL = 'technical', + CREATIVE = 'creative', + ANALYTICAL = 'analytical' +} + +export interface PersonalityTraits { + openness: number; // 0-1 + conscientiousness: number; + extraversion: number; + agreeableness: number; + neuroticism: number; +} + +export interface BehaviorPattern { + type: string; + frequency: number; + lastOccurrence: number; + confidence: number; // 0-1 +} + +export enum DataQuality { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high' +} + +// 記憶型 +export interface Memory { + id: string; + userId: string; + timestamp: number; + type: MemoryType; + content: string; + importance: number; // 0-1 + accessCount: number; + lastAccessed: number; + decay: number; // 0-1、1は減衰なし + metadata: MemoryMetadata; +} + +export enum MemoryType { + EPISODIC = 'episodic', + SEMANTIC = 'semantic', + PROCEDURAL = 'procedural', + WORKING = 'working' +} + +export interface MemoryMetadata { + entities: string[]; + emotions?: EmotionData; + context?: string; + source: MemorySource; + confidence: number; // 0-1 + tags: string[]; +} + +export interface EmotionData { + primary: string; + intensity: number; // 0-1 + valence: number; // -1 to 1 +} + +export enum MemorySource { + DIRECT_STATEMENT = 'direct_statement', + INFERRED = 'inferred', + OBSERVED = 'observed', + SYSTEM = 'system' +} + +// 短期記憶 +export interface ShortTermMemory { + sessionId: string; + userId: string; + startTime: number; + lastUpdate: number; + context: ConversationContext; + workingMemory: WorkingMemoryItem[]; + attention: AttentionFocus; +} + +export interface ConversationContext { + topic: string; + mood: string; + intent: string; + entities: Entity[]; + previousTopics: string[]; +} + +export interface WorkingMemoryItem { + content: string; + timestamp: number; + relevance: number; // 0-1 + type: 'user_input' | 'ai_response' | 'context'; +} + +export interface AttentionFocus { + primary: string; + secondary: string[]; + weights: Record; +} + +export interface Entity { + text: string; + type: EntityType; + confidence: number; + metadata?: Record; +} + +export enum EntityType { + PERSON = 'person', + LOCATION = 'location', + ORGANIZATION = 'organization', + DATE = 'date', + EVENT = 'event', + PRODUCT = 'product', + CONCEPT = 'concept', + OTHER = 'other' +} + +// ナレッジグラフ型 +export interface KnowledgeNode { + id: string; + userId: string; + type: NodeType; + label: string; + properties: Record; + createdAt: number; + updatedAt: number; + confidence: number; +} + +export interface KnowledgeEdge { + id: string; + sourceId: string; + targetId: string; + relationship: string; + properties: Record; + weight: number; + createdAt: number; +} + +export enum NodeType { + PERSON = 'person', + PLACE = 'place', + THING = 'thing', + CONCEPT = 'concept', + EVENT = 'event', + PREFERENCE = 'preference', + SKILL = 'skill' +} + +// ベクトル埋め込み型 +export interface VectorMemory { + id: string; + userId: string; + content: string; + embedding: number[]; + metadata: VectorMetadata; + timestamp: number; +} + +export interface VectorMetadata { + memoryId: string; + type: MemoryType; + importance: number; + tags: string[]; +} + +// ユーザー制御型 +export interface UserCommand { + command: string; + userId: string; + timestamp: number; +} + +export interface CommandResult { + success: boolean; + message: string; + data?: any; +} + +export interface MemoryStats { + total: number; + byType: Record; + averageImportance: number; + oldestMemory?: Date; + newestMemory?: Date; +} + +export interface MemoryQuery { + userId: string; + filters?: { + type?: MemoryType[]; + dateRange?: { start: number; end: number }; + importance?: { min: number; max: number }; + tags?: string[]; + search?: string; + }; + limit?: number; + offset?: number; +} + +export interface MemoryUpdate { + memoryId: string; + userId: string; + updates: { + content?: string; + importance?: number; + tags?: string[]; + metadata?: Partial; + }; +} + +export interface PrivacySettings { + userId: string; + allowInference: boolean; + allowLongTermStorage: boolean; + dataRetentionDays: number; + sensitiveTopics: string[]; + autoDeletePatterns: string[]; +} + +// 応答生成型 +export interface PersonalizedPrompt { + systemPrompt: string; + userContext: string; + relevantMemories: Memory[]; + relationshipContext: string; + styleGuidance: string; +} + +export interface ResponseContext { + userId: string; + message: string; + shortTermContext: ShortTermMemory; + longTermContext: Memory[]; + userProfile: UserProfile; + privacySettings: PrivacySettings; +} + +// 分析型 +export interface UserAnalytics { + userId: string; + metrics: { + totalInteractions: number; + averageSessionLength: number; + topTopics: { topic: string; count: number }[]; + satisfactionScore: number; + engagementScore: number; + }; + patterns: { + activeHours: number[]; + preferredTopics: string[]; + communicationPatterns: Record; + }; +} + +// エラー型 +export class PersonalizationError extends Error { + constructor( + message: string, + public code: PersonalizationErrorCode, + public details?: any + ) { + super(message); + this.name = 'PersonalizationError'; + } +} + +export enum PersonalizationErrorCode { + USER_NOT_FOUND = 'USER_NOT_FOUND', + MEMORY_NOT_FOUND = 'MEMORY_NOT_FOUND', + INVALID_INPUT = 'INVALID_INPUT', + STORAGE_ERROR = 'STORAGE_ERROR', + VECTOR_DB_ERROR = 'VECTOR_DB_ERROR', + PRIVACY_VIOLATION = 'PRIVACY_VIOLATION', + QUOTA_EXCEEDED = 'QUOTA_EXCEEDED' +} \ No newline at end of file diff --git a/test/personalization.test.ts b/test/personalization.test.ts new file mode 100644 index 00000000..860282ee --- /dev/null +++ b/test/personalization.test.ts @@ -0,0 +1,271 @@ +import loki from 'lokijs'; +import PersonalizationEngine from '../src/personalization/PersonalizationEngine.js'; +import { + MemoryType, + RelationshipLevel, + CommunicationStyle +} from '../src/personalization/types.js'; + +describe('Personalization System Tests', () => { + let db: loki; + let engine: PersonalizationEngine; + const testUserId = 'test-user-123'; + let sessionId: string; + + beforeEach(() => { + // Create in-memory database + db = new loki('test.db'); + engine = new PersonalizationEngine(db); + }); + + afterEach(() => { + engine.shutdown(); + }); + + describe('User Profile Management', () => { + test('should create new user profile on first interaction', async () => { + const result = await engine.processMessage( + testUserId, + 'Hello, my name is Alice!' + ); + + expect(result.personalizedPrompt).toBeDefined(); + + const stats = await engine.getStats(testUserId); + expect(stats.profile.relationshipLevel).toBe(RelationshipLevel.NEW_USER); + expect(stats.profile.totalInteractions).toBe(1); + }); + + test('should extract and store explicit information', async () => { + await engine.processMessage( + testUserId, + 'Hi, my name is Bob and I work as a software engineer.' + ); + + const stats = await engine.getStats(testUserId); + expect(stats.profile.dataQuality).not.toBe('low'); + }); + + test('should infer communication style', async () => { + // Formal style + await engine.processMessage( + testUserId, + 'Good morning. Could you please help me with this task? I would appreciate your assistance.' + ); + + // Technical style + await engine.processMessage( + testUserId, + 'I need to implement an API endpoint that handles database transactions.' + ); + + const result = await engine.processMessage( + testUserId, + 'How does the algorithm work?' + ); + + expect(result.personalizedPrompt?.styleGuidance).toContain('technical'); + }); + }); + + describe('Memory System', () => { + beforeEach(async () => { + sessionId = await engine.createSession(testUserId); + }); + + test('should store important facts as semantic memories', async () => { + await engine.processMessage( + testUserId, + 'I love playing guitar and I have a cat named Whiskers.', + sessionId + ); + + await engine.processResponse( + testUserId, + sessionId, + 'I love playing guitar and I have a cat named Whiskers.', + 'That\'s wonderful! Playing guitar is a great hobby.' + ); + + const stats = await engine.getStats(testUserId); + expect(stats.memoryStats.byType[MemoryType.SEMANTIC]).toBeGreaterThan(0); + }); + + test('should retrieve relevant memories for context', async () => { + // Store some memories + await engine.processMessage( + testUserId, + 'I work on machine learning projects.', + sessionId + ); + + await engine.processMessage( + testUserId, + 'My favorite programming language is Python.', + sessionId + ); + + // Query about related topic + const result = await engine.processMessage( + testUserId, + 'Can you help me with a Python ML problem?', + sessionId + ); + + expect(result.personalizedPrompt?.relevantMemories.length).toBeGreaterThan(0); + expect(result.personalizedPrompt?.userContext).toContain('machine learning'); + }); + }); + + describe('User Commands', () => { + test('should handle profile command', async () => { + // Create some history + await engine.processMessage(testUserId, 'My name is Charlie'); + await engine.processMessage(testUserId, 'I enjoy reading books'); + + const result = await engine.processMessage(testUserId, 'profile'); + + expect(result.commandResult?.success).toBe(true); + expect(result.commandResult?.message).toContain('あなたのプロフィール'); + expect(result.commandResult?.message).toContain('Charlie'); + }); + + test('should handle memories command', async () => { + sessionId = await engine.createSession(testUserId); + + // Create some memories + await engine.processMessage( + testUserId, + 'I graduated from MIT last year.', + sessionId + ); + + const result = await engine.processMessage(testUserId, 'memories'); + + expect(result.commandResult?.success).toBe(true); + expect(result.commandResult?.message).toContain('あなたの記憶'); + }); + + test('should handle forget command', async () => { + sessionId = await engine.createSession(testUserId); + + // Store a memory + await engine.processMessage( + testUserId, + 'My secret password is 12345.', + sessionId + ); + + // Forget it + const result = await engine.processMessage( + testUserId, + 'forget password' + ); + + expect(result.commandResult?.success).toBe(true); + expect(result.commandResult?.message).toContain('記憶を忘れました'); + }); + + test('should handle update_info command', async () => { + const result = await engine.processMessage( + testUserId, + 'update_info name=太郎 interests=音楽,アート location=東京' + ); + + expect(result.commandResult?.success).toBe(true); + expect(result.commandResult?.message).toContain('更新しました'); + + // Verify update + const profileResult = await engine.processMessage(testUserId, 'profile'); + expect(profileResult.commandResult?.message).toContain('太郎'); + expect(profileResult.commandResult?.message).toContain('音楽, アート'); + expect(profileResult.commandResult?.message).toContain('東京'); + }); + + test('should handle help command', async () => { + const result = await engine.processMessage(testUserId, 'help'); + + expect(result.commandResult?.success).toBe(true); + expect(result.commandResult?.message).toContain('記憶管理コマンド'); + }); + }); + + describe('Relationship Evolution', () => { + test('should upgrade relationship level with interactions', async () => { + sessionId = await engine.createSession(testUserId); + + // Initial state + let stats = await engine.getStats(testUserId); + expect(stats.profile.relationshipLevel).toBe(RelationshipLevel.NEW_USER); + + // Simulate multiple interactions + for (let i = 0; i < 6; i++) { + await engine.processMessage( + testUserId, + `This is interaction number ${i + 1}`, + sessionId + ); + } + + stats = await engine.getStats(testUserId); + expect(stats.profile.relationshipLevel).toBe(RelationshipLevel.ACQUAINTANCE); + }); + }); + + describe('Personalized Prompts', () => { + test('should generate different prompts based on relationship level', async () => { + sessionId = await engine.createSession(testUserId); + + // New user + let result = await engine.processMessage( + testUserId, + 'What can you do?', + sessionId + ); + + expect(result.personalizedPrompt?.relationshipContext).toContain('新しいユーザー'); + + // Simulate many interactions + for (let i = 0; i < 25; i++) { + await engine.processMessage(testUserId, `Message ${i}`, sessionId); + } + + // Familiar user + result = await engine.processMessage( + testUserId, + 'What can you do?', + sessionId + ); + + expect(result.personalizedPrompt?.relationshipContext).toContain('親しい'); + }); + + test('should include relevant past information in prompts', async () => { + sessionId = await engine.createSession(testUserId); + + // Store project information + await engine.processMessage( + testUserId, + 'I am working on a project called QuantumAI.', + sessionId + ); + + await engine.processMessage( + testUserId, + 'The QuantumAI project uses neural networks.', + sessionId + ); + + // Ask about the project + const result = await engine.processMessage( + testUserId, + 'Can you help me with my quantum project?', + sessionId + ); + + expect(result.personalizedPrompt?.relevantMemories.some( + m => m.content.includes('QuantumAI') + )).toBe(true); + }); + }); +}); \ No newline at end of file