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>
23 KiB
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.iosocket.ioopenai/@anthropic-ai/sdk
NPM Packages Frontend
socket.io-clientzustandreact-markdownremark-gfm
Notas de Implementación
- Reconexión automática: Socket.IO maneja reconexión, pero debemos restaurar estado
- Rate limiting: Implementar tanto en WS como en Redis para precisión
- Streaming: Usar Server-Sent Events como fallback si WS falla
- Tokens: Trackear uso de tokens para billing y límites
- Seguridad: Validar token JWT en cada conexión WS
Referencias
Especificación técnica - Sistema NEXUS Trading Platform