# 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

```typescript
// 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

```typescript
// 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

```typescript
// 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)

```typescript
// 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

```tsx
// 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

```typescript
// 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

```typescript
// 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

- [RF-LLM-001: Interfaz de Chat](../requerimientos/RF-LLM-001-chat-interface.md)
- [ET-LLM-006: Gestión de Memoria](./ET-LLM-006-gestion-memoria.md)

---

*Especificación técnica - Sistema NEXUS*
*OrbiQuant IA Trading Platform*
