--- id: "ET-LLM-006" title: "Gestión de Contexto y Memoria" type: "Technical Specification" status: "Done" priority: "Alta" epic: "OQI-007" project: "trading-platform" version: "1.0.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-LLM-006: Gestión de Contexto y Memoria **Épica:** OQI-007 - LLM Strategy Agent **Versión:** 1.0 **Fecha:** 2025-12-05 **Estado:** Planificado **Prioridad:** P0 - Crítico --- ## Resumen Esta especificación define el sistema de gestión de contexto y memoria del agente LLM, incluyendo el manejo de conversaciones, memoria a largo plazo, enriquecimiento automático de contexto y compresión de tokens. --- ## Arquitectura del Sistema ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ CONTEXT & MEMORY SYSTEM │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ Context Builder │ │ │ │ │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ User │ │ Market │ │ Conversation│ │ │ │ │ │ Context │ │ Context │ │ History │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ └─────────────────┼───────────────┘ │ │ │ │ ↓ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ │ │ Context Assembler │ │ │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ↓ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ Token Manager │ │ │ │ │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ │ │ Token │ │ Context │ │ Summarizer │ │ │ │ │ │ Counter │ │ Compressor │ │ │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ↓ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ Memory Store │ │ │ │ │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ │ │ Short-term │ │ Long-term │ │ Preferences │ │ │ │ │ │ (Redis) │ │ (Postgres) │ │ (Postgres) │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Context Builder ### Estructura del Contexto ```typescript // src/modules/copilot/context/context.types.ts interface FullContext { // User information user: UserContext; // Market information market: MarketContext; // Conversation history conversation: ConversationContext; // Long-term memory memory: MemoryContext; // System information system: SystemContext; } interface UserContext { id: string; name: string; plan: 'free' | 'pro' | 'premium'; riskProfile: 'conservative' | 'moderate' | 'aggressive'; experience: 'beginner' | 'intermediate' | 'advanced'; language: string; timezone: string; preferences: UserPreferences; } interface MarketContext { status: 'open' | 'closed' | 'pre_market' | 'after_hours'; currentTime: string; nextOpen?: string; nextClose?: string; upcomingEvents: MarketEvent[]; relevantSymbols: SymbolSnapshot[]; } interface ConversationContext { id: string; startedAt: string; messageCount: number; recentMessages: Message[]; summary?: string; recentTopics: string[]; mentionedSymbols: string[]; } interface MemoryContext { favoriteSymbols: string[]; tradingStyle: string; frequentQuestions: string[]; recentStrategies: StrategyRecord[]; userNotes: string[]; } interface SystemContext { currentDate: string; currentTime: string; agentVersion: string; availableTools: string[]; } ``` ### Context Builder Service ```typescript // src/modules/copilot/context/context-builder.ts @Injectable() export class ContextBuilder { constructor( private readonly userService: UserService, private readonly marketService: MarketService, private readonly conversationService: ConversationService, private readonly memoryService: MemoryService, private readonly portfolioService: PortfolioService, private readonly alertService: AlertService, ) {} async build(params: BuildContextParams): Promise { const { userId, conversationId, currentMessage } = params; // Fetch all context data in parallel const [ user, marketStatus, conversation, memory, portfolio, alerts, ] = await Promise.all([ this.userService.getProfile(userId), this.marketService.getMarketStatus(), this.conversationService.getContext(conversationId), this.memoryService.getUserMemory(userId), this.portfolioService.getPositions(userId), this.alertService.getActive(userId), ]); // Extract mentioned symbols from current message const mentionedSymbols = this.extractSymbols(currentMessage); // Get relevant symbol snapshots const relevantSymbols = await this.getRelevantSymbols( mentionedSymbols, memory.favoriteSymbols, portfolio.map(p => p.symbol), ); // Build upcoming events const upcomingEvents = await this.marketService.getUpcomingEvents( relevantSymbols.map(s => s.symbol), ); return { user: { id: user.id, name: user.firstName, plan: user.plan, riskProfile: user.riskProfile, experience: user.experience, language: user.language || 'es', timezone: user.timezone || 'America/Mexico_City', preferences: user.preferences, }, market: { status: marketStatus.status, currentTime: new Date().toISOString(), nextOpen: marketStatus.nextOpen, nextClose: marketStatus.nextClose, upcomingEvents, relevantSymbols, }, conversation: { id: conversationId, startedAt: conversation.startedAt, messageCount: conversation.messageCount, recentMessages: conversation.recentMessages, summary: conversation.summary, recentTopics: conversation.topics, mentionedSymbols, }, memory: { favoriteSymbols: memory.favoriteSymbols, tradingStyle: memory.tradingStyle, frequentQuestions: memory.frequentQuestions, recentStrategies: memory.recentStrategies, userNotes: memory.userNotes, }, system: { currentDate: new Date().toISOString().split('T')[0], currentTime: new Date().toISOString(), agentVersion: '1.0.0', availableTools: this.getAvailableTools(user.plan), }, }; } private extractSymbols(message: string): string[] { // Pattern for stock symbols (uppercase, 1-5 chars) const stockPattern = /\b[A-Z]{1,5}\b/g; // Pattern for crypto pairs const cryptoPattern = /\b(BTC|ETH|SOL|ADA|XRP)\/?(USD|USDT)?\b/gi; const matches = [ ...(message.match(stockPattern) || []), ...(message.match(cryptoPattern) || []), ]; // Filter out common words that look like symbols const commonWords = new Set(['I', 'A', 'THE', 'AND', 'OR', 'FOR', 'IS', 'IT', 'MY']); return [...new Set(matches)] .filter(s => !commonWords.has(s.toUpperCase())) .map(s => s.toUpperCase()); } private async getRelevantSymbols( mentioned: string[], favorites: string[], portfolio: string[], ): Promise { // Combine and dedupe symbols const allSymbols = [...new Set([...mentioned, ...favorites.slice(0, 3), ...portfolio])]; // Limit to 10 symbols max const limitedSymbols = allSymbols.slice(0, 10); // Get snapshots return Promise.all( limitedSymbols.map(symbol => this.marketService.getSnapshot(symbol)) ); } } ``` --- ## Token Manager ### Token Counter ```typescript // src/modules/copilot/context/token-counter.ts import { encoding_for_model, TiktokenModel } from 'tiktoken'; @Injectable() export class TokenCounter { private encoder: any; constructor() { // Use cl100k_base for GPT-4 and Claude this.encoder = encoding_for_model('gpt-4' as TiktokenModel); } count(text: string): number { return this.encoder.encode(text).length; } countMessages(messages: Message[]): number { let total = 0; for (const msg of messages) { // Each message has overhead of ~4 tokens total += 4; total += this.count(msg.content); if (msg.role) { total += this.count(msg.role); } } return total; } estimateContextTokens(context: FullContext): TokenEstimate { const systemPromptTokens = this.count(this.formatSystemPrompt(context)); const messagesTokens = this.countMessages(context.conversation.recentMessages); const toolsTokens = context.system.availableTools.length * 150; // ~150 tokens per tool return { systemPrompt: systemPromptTokens, messages: messagesTokens, tools: toolsTokens, total: systemPromptTokens + messagesTokens + toolsTokens, remaining: 8000 - (systemPromptTokens + messagesTokens + toolsTokens), }; } private formatSystemPrompt(context: FullContext): string { // Template for system prompt (simplified) return `User: ${context.user.name}, Plan: ${context.user.plan}...`; } } ``` ### Context Compressor ```typescript // src/modules/copilot/context/context-compressor.ts interface CompressionResult { context: FullContext; compressed: boolean; originalTokens: number; compressedTokens: number; strategies: string[]; } @Injectable() export class ContextCompressor { private readonly maxTokens = 6000; // Leave room for response private readonly targetTokens = 4000; constructor( private readonly tokenCounter: TokenCounter, private readonly summarizer: ConversationSummarizer, ) {} async compress(context: FullContext): Promise { const estimate = this.tokenCounter.estimateContextTokens(context); const strategies: string[] = []; if (estimate.total <= this.maxTokens) { return { context, compressed: false, originalTokens: estimate.total, compressedTokens: estimate.total, strategies: [], }; } let compressedContext = { ...context }; // Strategy 1: Reduce market context if (estimate.total > this.maxTokens) { compressedContext = this.reduceMarketContext(compressedContext); strategies.push('reduce_market_context'); } // Strategy 2: Trim conversation history const newEstimate = this.tokenCounter.estimateContextTokens(compressedContext); if (newEstimate.total > this.maxTokens) { compressedContext = await this.trimConversation(compressedContext); strategies.push('trim_conversation'); } // Strategy 3: Summarize old messages const finalEstimate = this.tokenCounter.estimateContextTokens(compressedContext); if (finalEstimate.total > this.maxTokens) { compressedContext = await this.summarizeConversation(compressedContext); strategies.push('summarize_conversation'); } return { context: compressedContext, compressed: true, originalTokens: estimate.total, compressedTokens: this.tokenCounter.estimateContextTokens(compressedContext).total, strategies, }; } private reduceMarketContext(context: FullContext): FullContext { return { ...context, market: { ...context.market, // Keep only essential symbols (mentioned + portfolio) relevantSymbols: context.market.relevantSymbols.slice(0, 5), // Keep only high-impact events upcomingEvents: context.market.upcomingEvents.filter(e => e.impact === 'high'), }, }; } private async trimConversation(context: FullContext): Promise { const { recentMessages } = context.conversation; // Keep last 10 messages const trimmedMessages = recentMessages.slice(-10); return { ...context, conversation: { ...context.conversation, recentMessages: trimmedMessages, }, }; } private async summarizeConversation(context: FullContext): Promise { const { recentMessages } = context.conversation; if (recentMessages.length <= 5) { return context; } // Summarize older messages const oldMessages = recentMessages.slice(0, -5); const recentMessages5 = recentMessages.slice(-5); const summary = await this.summarizer.summarize(oldMessages); return { ...context, conversation: { ...context.conversation, summary: summary, recentMessages: recentMessages5, }, }; } } ``` ### Conversation Summarizer ```typescript // src/modules/copilot/context/summarizer.ts @Injectable() export class ConversationSummarizer { constructor(private readonly llmProvider: LLMProviderService) {} async summarize(messages: Message[]): Promise { if (messages.length === 0) return ''; const formattedMessages = messages.map(m => `${m.role}: ${m.content}` ).join('\n'); const response = await this.llmProvider.createChatCompletion({ model: 'gpt-4o-mini', // Use smaller model for summarization messages: [ { role: 'system', content: `Eres un asistente que resume conversaciones de trading. Crea un resumen conciso (máximo 100 palabras) que capture: - Símbolos mencionados - Análisis o estrategias discutidas - Decisiones o conclusiones importantes - Preguntas pendientes del usuario Formato: Lista de puntos clave.`, }, { role: 'user', content: `Resume esta conversación:\n\n${formattedMessages}`, }, ], max_tokens: 200, }); return response.choices[0].message.content; } } ``` --- ## Memory Store ### Short-term Memory (Redis) ```typescript // src/modules/copilot/memory/short-term-memory.ts interface ShortTermData { activeConversation: string; recentSymbols: string[]; lastActivity: string; pendingActions: PendingAction[]; } @Injectable() export class ShortTermMemory { private readonly ttl = 3600; // 1 hour constructor(private readonly redis: RedisService) {} async get(userId: string): Promise { const key = `stm:${userId}`; const data = await this.redis.get(key); return data ? JSON.parse(data) : null; } async set(userId: string, data: Partial): Promise { const key = `stm:${userId}`; const existing = await this.get(userId); const merged = { ...existing, ...data }; await this.redis.setex(key, this.ttl, JSON.stringify(merged)); } async addRecentSymbol(userId: string, symbol: string): Promise { const data = await this.get(userId); const recentSymbols = data?.recentSymbols || []; // Add to front, dedupe, limit to 10 const updated = [symbol, ...recentSymbols.filter(s => s !== symbol)].slice(0, 10); await this.set(userId, { recentSymbols: updated }); } async addPendingAction(userId: string, action: PendingAction): Promise { const data = await this.get(userId); const pendingActions = data?.pendingActions || []; await this.set(userId, { pendingActions: [...pendingActions, action], }); } async clearPendingActions(userId: string): Promise { await this.set(userId, { pendingActions: [] }); } } ``` ### Long-term Memory (PostgreSQL) ```typescript // src/modules/copilot/memory/long-term-memory.ts @Entity('user_memory') export class UserMemory { @PrimaryColumn() userId: string; @Column({ type: 'jsonb', default: [] }) favoriteSymbols: string[]; @Column({ nullable: true }) tradingStyle: string; @Column({ type: 'jsonb', default: [] }) frequentQuestions: string[]; @Column({ type: 'jsonb', default: [] }) recentStrategies: StrategyRecord[]; @Column({ type: 'jsonb', default: [] }) userNotes: string[]; @Column({ type: 'jsonb', default: {} }) preferences: Record; @UpdateDateColumn() updatedAt: Date; } @Injectable() export class LongTermMemory { constructor( @InjectRepository(UserMemory) private readonly memoryRepo: Repository, ) {} async get(userId: string): Promise { let memory = await this.memoryRepo.findOne({ where: { userId } }); if (!memory) { memory = this.memoryRepo.create({ userId, favoriteSymbols: [], frequentQuestions: [], recentStrategies: [], userNotes: [], preferences: {}, }); await this.memoryRepo.save(memory); } return memory; } async update(userId: string, updates: Partial): Promise { await this.memoryRepo.update({ userId }, updates); } async addFavoriteSymbol(userId: string, symbol: string): Promise { const memory = await this.get(userId); if (!memory.favoriteSymbols.includes(symbol)) { memory.favoriteSymbols = [symbol, ...memory.favoriteSymbols].slice(0, 20); await this.memoryRepo.save(memory); } } async addStrategy(userId: string, strategy: StrategyRecord): Promise { const memory = await this.get(userId); memory.recentStrategies = [strategy, ...memory.recentStrategies].slice(0, 10); await this.memoryRepo.save(memory); } async learnFromConversation( userId: string, conversation: Message[], ): Promise { // Extract patterns from conversation const patterns = this.extractPatterns(conversation); const memory = await this.get(userId); // Update frequent questions if (patterns.questions.length > 0) { const updated = this.mergeFrequentQuestions( memory.frequentQuestions, patterns.questions, ); memory.frequentQuestions = updated; } // Update trading style if (patterns.tradingStyle) { memory.tradingStyle = patterns.tradingStyle; } // Update preferences if (patterns.preferences) { memory.preferences = { ...memory.preferences, ...patterns.preferences }; } await this.memoryRepo.save(memory); } private extractPatterns(conversation: Message[]): ExtractedPatterns { const patterns: ExtractedPatterns = { questions: [], tradingStyle: null, preferences: {}, }; for (const msg of conversation) { if (msg.role === 'user') { // Detect questions if (msg.content.includes('?')) { const question = this.normalizeQuestion(msg.content); patterns.questions.push(question); } // Detect trading style mentions if (msg.content.match(/swing|day trading|scalping|largo plazo/i)) { patterns.tradingStyle = this.detectTradingStyle(msg.content); } // Detect preferences if (msg.content.match(/prefiero|me gusta|siempre/i)) { const prefs = this.extractPreferences(msg.content); patterns.preferences = { ...patterns.preferences, ...prefs }; } } } return patterns; } private normalizeQuestion(question: string): string { // Remove specific symbols/values to get question template return question .replace(/\$[\d,]+/g, '[AMOUNT]') .replace(/\b[A-Z]{1,5}\b/g, '[SYMBOL]') .substring(0, 100); } private mergeFrequentQuestions( existing: string[], newQuestions: string[], ): string[] { const merged = [...existing]; for (const q of newQuestions) { // Check for similar question const similar = merged.find(eq => this.questionSimilarity(eq, q) > 0.8 ); if (!similar) { merged.push(q); } } return merged.slice(0, 20); } private questionSimilarity(q1: string, q2: string): number { // Simple word overlap similarity const words1 = new Set(q1.toLowerCase().split(/\s+/)); const words2 = new Set(q2.toLowerCase().split(/\s+/)); const intersection = [...words1].filter(w => words2.has(w)).length; const union = new Set([...words1, ...words2]).size; return intersection / union; } } ``` --- ## System Prompt Builder ```typescript // src/modules/copilot/context/system-prompt-builder.ts @Injectable() export class SystemPromptBuilder { build(context: FullContext): string { const sections = [ this.buildHeader(), this.buildUserSection(context.user), this.buildMarketSection(context.market), this.buildMemorySection(context.memory), this.buildConversationSection(context.conversation), this.buildInstructions(), ]; return sections.join('\n\n'); } private buildHeader(): string { return `# Trading Platform Trading Copilot Eres un asistente especializado en trading e inversiones. Tu objetivo es ayudar al usuario con análisis de mercado, estrategias de trading, educación financiera y gestión de portfolio.`; } private buildUserSection(user: UserContext): string { return `## Usuario - **Nombre:** ${user.name} - **Plan:** ${user.plan} - **Perfil de riesgo:** ${user.riskProfile} - **Experiencia:** ${user.experience} - **Idioma:** ${user.language} - **Zona horaria:** ${user.timezone}`; } private buildMarketSection(market: MarketContext): string { let section = `## Contexto de Mercado - **Estado:** ${this.formatMarketStatus(market.status)} - **Hora actual:** ${market.currentTime}`; if (market.nextOpen) { section += `\n- **Próxima apertura:** ${market.nextOpen}`; } if (market.relevantSymbols.length > 0) { section += `\n\n### Símbolos Relevantes`; for (const symbol of market.relevantSymbols.slice(0, 5)) { section += `\n- ${symbol.symbol}: $${symbol.price} (${symbol.changePercent > 0 ? '+' : ''}${symbol.changePercent}%)`; } } if (market.upcomingEvents.length > 0) { section += `\n\n### Eventos Próximos`; for (const event of market.upcomingEvents.slice(0, 3)) { section += `\n- ${event.date}: ${event.name} (${event.impact})`; } } return section; } private buildMemorySection(memory: MemoryContext): string { if (!memory.favoriteSymbols.length && !memory.tradingStyle) { return ''; } let section = `## Lo que sé del usuario`; if (memory.favoriteSymbols.length > 0) { section += `\n- **Símbolos favoritos:** ${memory.favoriteSymbols.slice(0, 5).join(', ')}`; } if (memory.tradingStyle) { section += `\n- **Estilo de trading:** ${memory.tradingStyle}`; } if (memory.recentStrategies.length > 0) { section += `\n- **Estrategia reciente:** ${memory.recentStrategies[0].name}`; } return section; } private buildConversationSection(conversation: ConversationContext): string { if (!conversation.summary && conversation.recentTopics.length === 0) { return ''; } let section = `## Contexto de Conversación`; if (conversation.summary) { section += `\n### Resumen previo\n${conversation.summary}`; } if (conversation.recentTopics.length > 0) { section += `\n- **Temas recientes:** ${conversation.recentTopics.join(', ')}`; } if (conversation.mentionedSymbols.length > 0) { section += `\n- **Símbolos mencionados:** ${conversation.mentionedSymbols.join(', ')}`; } return section; } private buildInstructions(): string { return `## Instrucciones 1. Usa las herramientas disponibles para obtener datos actualizados 2. Siempre incluye disclaimers en análisis financieros 3. Adapta tu lenguaje al nivel de experiencia del usuario 4. Si el usuario pregunta algo que requiere un plan superior, informa educadamente 5. Sé conciso pero completo en tus respuestas 6. Si no tienes datos suficientes, dilo claramente 7. Recuerda el contexto de la conversación`; } private formatMarketStatus(status: string): string { const statusMap = { open: 'Mercado Abierto', closed: 'Mercado Cerrado', pre_market: 'Pre-mercado', after_hours: 'After Hours', }; return statusMap[status] || status; } } ``` --- ## Dependencias ### Base de Datos - PostgreSQL: user_memory, conversations, messages - Redis: short-term memory, cache ### Bibliotecas - tiktoken (conteo de tokens) - @anthropic-ai/sdk / openai (LLM calls) --- ## Referencias - [RF-LLM-006: Gestión de Contexto](../requerimientos/RF-LLM-006-context-management.md) - [ET-LLM-001: Arquitectura del Chat](./ET-LLM-001-arquitectura-chat.md) --- *Especificación técnica - Sistema NEXUS* *Trading Platform*