trading-platform/docs/02-definicion-modulos/OQI-007-llm-agent/especificaciones/ET-LLM-001-arquitectura-chat.md
rckrdmrd c1b5081208 feat(ml): Complete FASE 11 - BTCUSD update and comprehensive documentation alignment
ML Engine Updates:
- Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records
- Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence)
- Backtest results: +176.71R profit with aggressive_filter strategy

Documentation Consolidation:
- Created docs/99-analisis/_MAP.md index with 13 new analysis documents
- Consolidated inventories: removed duplicates from orchestration/inventarios/
- Updated ML_INVENTORY.yml with BTCUSD metrics and training results
- Added execution reports: FASE11-BTCUSD, correction issues, alignment validation

Architecture & Integration:
- Updated all module documentation with NEXUS v3.4 frontmatter
- Fixed _MAP.md indexes across all folders
- Updated orchestration plans and traces

Files: 229 changed, 5064 insertions(+), 1872 deletions(-)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:31:29 -06:00

23 KiB

id title type status priority epic project version created_date updated_date
ET-LLM-001 Arquitectura del Sistema de Chat Technical Specification Done Alta OQI-007 trading-platform 1.0.0 2025-12-05 2026-01-04

ET-LLM-001: Arquitectura del Sistema de Chat

É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 la arquitectura técnica del sistema de chat conversacional con el agente LLM, incluyendo backend, frontend, streaming y persistencia.


Arquitectura General

┌─────────────────────────────────────────────────────────────────────────┐
│                              FRONTEND                                    │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────────┐  │
│  │   ChatWindow    │  │  MessageList    │  │    MessageInput         │  │
│  │   Component     │  │  Component      │  │    Component            │  │
│  └────────┬────────┘  └────────┬────────┘  └───────────┬─────────────┘  │
│           └────────────────────┴───────────────────────┘                │
│                                │                                         │
│  ┌─────────────────────────────┴─────────────────────────────────────┐  │
│  │                      WebSocket Client                              │  │
│  │                  (Socket.IO / native WS)                           │  │
│  └─────────────────────────────┬─────────────────────────────────────┘  │
└────────────────────────────────┼────────────────────────────────────────┘
                                 │ WSS
┌────────────────────────────────┼────────────────────────────────────────┐
│                              BACKEND                                     │
│  ┌─────────────────────────────┴─────────────────────────────────────┐  │
│  │                    WebSocket Gateway                               │  │
│  │               (NestJS Gateway / Express WS)                        │  │
│  └────────┬────────────────────┬────────────────────┬────────────────┘  │
│           │                    │                    │                    │
│  ┌────────▼────────┐  ┌────────▼────────┐  ┌───────▼─────────┐         │
│  │  ChatService    │  │  AgentService   │  │  ToolsService   │         │
│  │                 │  │                 │  │                 │         │
│  │ - createConv()  │  │ - processMsg()  │  │ - get_price()   │         │
│  │ - getHistory()  │  │ - buildCtx()    │  │ - create_order()│         │
│  │ - saveMessage() │  │ - streamResp()  │  │ - get_ml()      │         │
│  └────────┬────────┘  └────────┬────────┘  └───────┬─────────┘         │
│           │                    │                    │                    │
│  ┌────────▼────────────────────▼────────────────────▼────────────────┐  │
│  │                      LLM Provider Adapter                          │  │
│  │            (OpenAI / Claude / Local Model)                         │  │
│  └───────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────────┘
                                 │
┌────────────────────────────────┼────────────────────────────────────────┐
│                            STORAGE                                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                   │
│  │  PostgreSQL  │  │    Redis     │  │    S3/Blob   │                   │
│  │              │  │              │  │              │                   │
│  │ conversations│  │ session cache│  │  attachments │                   │
│  │ messages     │  │ rate limits  │  │  exports     │                   │
│  │ user_memory  │  │ active WS    │  │              │                   │
│  └──────────────┘  └──────────────┘  └──────────────┘                   │
└──────────────────────────────────────────────────────────────────────────┘

Componentes Backend

1. WebSocket Gateway

// src/modules/copilot/gateways/chat.gateway.ts

import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  OnGatewayConnection,
  OnGatewayDisconnect,
  ConnectedSocket,
  MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({
  namespace: '/copilot',
  cors: {
    origin: process.env.FRONTEND_URL,
    credentials: true,
  },
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  constructor(
    private readonly chatService: ChatService,
    private readonly agentService: AgentService,
    private readonly authService: AuthService,
  ) {}

  async handleConnection(client: Socket) {
    try {
      const token = client.handshake.auth.token;
      const user = await this.authService.validateToken(token);
      client.data.user = user;
      client.join(`user:${user.id}`);

      // Cargar conversación activa si existe
      const activeConv = await this.chatService.getActiveConversation(user.id);
      if (activeConv) {
        client.emit('conversation:restored', activeConv);
      }
    } catch (error) {
      client.disconnect();
    }
  }

  handleDisconnect(client: Socket) {
    // Cleanup
  }

  @SubscribeMessage('message:send')
  async handleMessage(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { conversationId: string; content: string },
  ) {
    const user = client.data.user;

    // Rate limiting check
    const canSend = await this.chatService.checkRateLimit(user.id, user.plan);
    if (!canSend) {
      client.emit('error', { code: 'RATE_LIMIT', message: 'Daily limit reached' });
      return;
    }

    // Save user message
    const userMessage = await this.chatService.saveMessage({
      conversationId: data.conversationId,
      role: 'user',
      content: data.content,
    });

    client.emit('message:saved', userMessage);
    client.emit('agent:thinking', { status: 'processing' });

    // Process with agent (streaming)
    const stream = await this.agentService.processMessage({
      userId: user.id,
      conversationId: data.conversationId,
      message: data.content,
      userPlan: user.plan,
    });

    let fullResponse = '';

    for await (const chunk of stream) {
      fullResponse += chunk.content;
      client.emit('agent:stream', {
        chunk: chunk.content,
        toolCalls: chunk.toolCalls,
      });
    }

    // Save complete response
    const agentMessage = await this.chatService.saveMessage({
      conversationId: data.conversationId,
      role: 'assistant',
      content: fullResponse,
    });

    client.emit('agent:complete', agentMessage);
  }

  @SubscribeMessage('message:cancel')
  async handleCancel(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { conversationId: string },
  ) {
    await this.agentService.cancelGeneration(data.conversationId);
    client.emit('agent:cancelled');
  }
}

2. Chat Service

// src/modules/copilot/services/chat.service.ts

@Injectable()
export class ChatService {
  constructor(
    @InjectRepository(Conversation)
    private conversationRepo: Repository<Conversation>,
    @InjectRepository(Message)
    private messageRepo: Repository<Message>,
    private readonly redis: RedisService,
  ) {}

  async createConversation(userId: string, title?: string): Promise<Conversation> {
    const conversation = this.conversationRepo.create({
      userId,
      title: title || `Conversación ${new Date().toLocaleDateString()}`,
      status: 'active',
    });
    return this.conversationRepo.save(conversation);
  }

  async getConversations(userId: string, limit = 20): Promise<Conversation[]> {
    return this.conversationRepo.find({
      where: { userId, deletedAt: null },
      order: { updatedAt: 'DESC' },
      take: limit,
    });
  }

  async getConversationMessages(
    conversationId: string,
    limit = 50,
    offset = 0,
  ): Promise<Message[]> {
    return this.messageRepo.find({
      where: { conversationId },
      order: { createdAt: 'ASC' },
      take: limit,
      skip: offset,
    });
  }

  async saveMessage(data: CreateMessageDto): Promise<Message> {
    const message = this.messageRepo.create(data);
    const saved = await this.messageRepo.save(message);

    // Update conversation timestamp
    await this.conversationRepo.update(
      { id: data.conversationId },
      { updatedAt: new Date() },
    );

    return saved;
  }

  async checkRateLimit(userId: string, plan: string): Promise<boolean> {
    const limits = {
      free: 10,
      pro: 100,
      premium: -1, // unlimited
    };

    if (plan === 'premium') return true;

    const key = `rate:chat:${userId}:${new Date().toISOString().split('T')[0]}`;
    const count = await this.redis.incr(key);

    if (count === 1) {
      await this.redis.expire(key, 86400); // 24 hours
    }

    return count <= limits[plan];
  }

  async deleteConversation(conversationId: string, userId: string): Promise<void> {
    await this.conversationRepo.update(
      { id: conversationId, userId },
      { deletedAt: new Date() },
    );
  }

  async renameConversation(
    conversationId: string,
    userId: string,
    title: string,
  ): Promise<Conversation> {
    await this.conversationRepo.update(
      { id: conversationId, userId },
      { title },
    );
    return this.conversationRepo.findOne({ where: { id: conversationId } });
  }
}

3. Agent Service

// src/modules/copilot/services/agent.service.ts

@Injectable()
export class AgentService {
  private activeGenerations: Map<string, AbortController> = new Map();

  constructor(
    private readonly llmProvider: LLMProviderService,
    private readonly contextService: ContextService,
    private readonly toolsService: ToolsService,
  ) {}

  async *processMessage(params: ProcessMessageParams): AsyncGenerator<StreamChunk> {
    const { userId, conversationId, message, userPlan } = params;

    const abortController = new AbortController();
    this.activeGenerations.set(conversationId, abortController);

    try {
      // Build context
      const context = await this.contextService.buildContext({
        userId,
        conversationId,
        currentMessage: message,
        userPlan,
      });

      // Define available tools based on plan
      const tools = this.toolsService.getAvailableTools(userPlan);

      // Create LLM request
      const stream = await this.llmProvider.createChatCompletion({
        model: this.getModelForPlan(userPlan),
        messages: [
          { role: 'system', content: context.systemPrompt },
          ...context.conversationHistory,
          { role: 'user', content: message },
        ],
        tools,
        stream: true,
        signal: abortController.signal,
      });

      // Process stream
      for await (const chunk of stream) {
        if (abortController.signal.aborted) {
          break;
        }

        // Handle tool calls
        if (chunk.toolCalls) {
          for (const toolCall of chunk.toolCalls) {
            const result = await this.toolsService.execute(
              toolCall.name,
              toolCall.arguments,
              userId,
            );
            yield {
              type: 'tool_result',
              content: '',
              toolCalls: [{ ...toolCall, result }],
            };
          }
        }

        // Yield text content
        if (chunk.content) {
          yield {
            type: 'text',
            content: chunk.content,
            toolCalls: null,
          };
        }
      }
    } finally {
      this.activeGenerations.delete(conversationId);
    }
  }

  async cancelGeneration(conversationId: string): Promise<void> {
    const controller = this.activeGenerations.get(conversationId);
    if (controller) {
      controller.abort();
    }
  }

  private getModelForPlan(plan: string): string {
    const models = {
      free: 'gpt-4o-mini',
      pro: 'gpt-4o',
      premium: 'claude-3-5-sonnet',
    };
    return models[plan] || models.free;
  }
}

Componentes Frontend

1. Chat Store (Zustand)

// src/stores/chat.store.ts

import { create } from 'zustand';
import { io, Socket } from 'socket.io-client';

interface ChatState {
  socket: Socket | null;
  connected: boolean;
  conversations: Conversation[];
  activeConversation: Conversation | null;
  messages: Message[];
  isStreaming: boolean;
  streamingContent: string;

  // Actions
  connect: (token: string) => void;
  disconnect: () => void;
  sendMessage: (content: string) => void;
  cancelGeneration: () => void;
  createConversation: () => void;
  selectConversation: (id: string) => void;
  deleteConversation: (id: string) => void;
}

export const useChatStore = create<ChatState>((set, get) => ({
  socket: null,
  connected: false,
  conversations: [],
  activeConversation: null,
  messages: [],
  isStreaming: false,
  streamingContent: '',

  connect: (token: string) => {
    const socket = io(`${API_URL}/copilot`, {
      auth: { token },
      transports: ['websocket'],
    });

    socket.on('connect', () => {
      set({ connected: true });
    });

    socket.on('disconnect', () => {
      set({ connected: false });
    });

    socket.on('conversation:restored', (conversation) => {
      set({ activeConversation: conversation });
    });

    socket.on('message:saved', (message) => {
      set((state) => ({
        messages: [...state.messages, message],
      }));
    });

    socket.on('agent:thinking', () => {
      set({ isStreaming: true, streamingContent: '' });
    });

    socket.on('agent:stream', ({ chunk }) => {
      set((state) => ({
        streamingContent: state.streamingContent + chunk,
      }));
    });

    socket.on('agent:complete', (message) => {
      set((state) => ({
        messages: [...state.messages, message],
        isStreaming: false,
        streamingContent: '',
      }));
    });

    socket.on('agent:cancelled', () => {
      set({ isStreaming: false });
    });

    set({ socket });
  },

  disconnect: () => {
    get().socket?.disconnect();
    set({ socket: null, connected: false });
  },

  sendMessage: (content: string) => {
    const { socket, activeConversation } = get();
    if (!socket || !activeConversation) return;

    socket.emit('message:send', {
      conversationId: activeConversation.id,
      content,
    });
  },

  cancelGeneration: () => {
    const { socket, activeConversation } = get();
    if (!socket || !activeConversation) return;

    socket.emit('message:cancel', {
      conversationId: activeConversation.id,
    });
  },

  // ... other actions
}));

2. Chat Component

// src/modules/copilot/components/ChatWindow.tsx

import { useEffect, useRef } from 'react';
import { useChatStore } from '@/stores/chat.store';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { ConversationSidebar } from './ConversationSidebar';

export function ChatWindow() {
  const {
    connected,
    connect,
    messages,
    isStreaming,
    streamingContent,
    sendMessage,
    cancelGeneration,
  } = useChatStore();

  const messagesEndRef = useRef<HTMLDivElement>(null);
  const { token } = useAuth();

  useEffect(() => {
    if (token && !connected) {
      connect(token);
    }
    return () => {
      useChatStore.getState().disconnect();
    };
  }, [token]);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, streamingContent]);

  return (
    <div className="flex h-full">
      <ConversationSidebar />

      <div className="flex-1 flex flex-col">
        <div className="flex-1 overflow-y-auto p-4">
          <MessageList messages={messages} />

          {isStreaming && (
            <div className="animate-pulse">
              <MessageBubble role="assistant" content={streamingContent} />
            </div>
          )}

          <div ref={messagesEndRef} />
        </div>

        <MessageInput
          onSend={sendMessage}
          onCancel={cancelGeneration}
          isStreaming={isStreaming}
          disabled={!connected}
        />
      </div>
    </div>
  );
}

Modelos de Datos

Conversation Entity

// src/modules/copilot/entities/conversation.entity.ts

@Entity('conversations')
export class Conversation {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'user_id' })
  userId: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ length: 255 })
  title: string;

  @Column({
    type: 'enum',
    enum: ['active', 'archived'],
    default: 'active',
  })
  status: string;

  @Column({ type: 'jsonb', nullable: true })
  metadata: Record<string, any>;

  @Column({ name: 'messages_count', default: 0 })
  messagesCount: number;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  @DeleteDateColumn({ name: 'deleted_at' })
  deletedAt: Date;

  @OneToMany(() => Message, (message) => message.conversation)
  messages: Message[];
}

Message Entity

// src/modules/copilot/entities/message.entity.ts

@Entity('messages')
export class Message {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'conversation_id' })
  conversationId: string;

  @ManyToOne(() => Conversation, (conv) => conv.messages)
  @JoinColumn({ name: 'conversation_id' })
  conversation: Conversation;

  @Column({
    type: 'enum',
    enum: ['user', 'assistant', 'system'],
  })
  role: string;

  @Column({ type: 'text' })
  content: string;

  @Column({ type: 'jsonb', nullable: true, name: 'tool_calls' })
  toolCalls: ToolCall[];

  @Column({ type: 'jsonb', nullable: true })
  metadata: Record<string, any>;

  @Column({ type: 'smallint', nullable: true, name: 'feedback_rating' })
  feedbackRating: number;

  @Column({ type: 'text', nullable: true, name: 'feedback_comment' })
  feedbackComment: string;

  @Column({ name: 'tokens_input', nullable: true })
  tokensInput: number;

  @Column({ name: 'tokens_output', nullable: true })
  tokensOutput: number;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

interface ToolCall {
  id: string;
  name: string;
  arguments: Record<string, any>;
  result?: any;
}

API REST Endpoints

Method Endpoint Descripción
GET /api/copilot/conversations Listar conversaciones
POST /api/copilot/conversations Crear conversación
GET /api/copilot/conversations/:id Obtener conversación
PATCH /api/copilot/conversations/:id Renombrar conversación
DELETE /api/copilot/conversations/:id Eliminar conversación
GET /api/copilot/conversations/:id/messages Obtener mensajes
POST /api/copilot/messages/:id/feedback Dar feedback a mensaje

WebSocket Events

Client → Server

Event Payload Descripción
message:send { conversationId, content } Enviar mensaje
message:cancel { conversationId } Cancelar generación
conversation:create { title? } Crear conversación
typing:start { conversationId } Usuario escribiendo

Server → Client

Event Payload Descripción
message:saved Message Mensaje guardado
agent:thinking { status } Agente procesando
agent:stream { chunk, toolCalls? } Chunk de respuesta
agent:complete Message Respuesta completa
agent:cancelled {} Generación cancelada
error { code, message } Error
conversation:restored Conversation Conversación restaurada

Dependencias

NPM Packages Backend

  • @nestjs/websockets
  • @nestjs/platform-socket.io
  • socket.io
  • openai / @anthropic-ai/sdk

NPM Packages Frontend

  • socket.io-client
  • zustand
  • react-markdown
  • remark-gfm

Notas de Implementación

  1. Reconexión automática: Socket.IO maneja reconexión, pero debemos restaurar estado
  2. Rate limiting: Implementar tanto en WS como en Redis para precisión
  3. Streaming: Usar Server-Sent Events como fallback si WS falla
  4. Tokens: Trackear uso de tokens para billing y límites
  5. Seguridad: Validar token JWT en cada conexión WS

Referencias


Especificación técnica - Sistema NEXUS Trading Platform