feat: implement AI conversations schema management and ensure data integrity in AI service

This commit is contained in:
2025-10-04 05:03:39 -05:00
parent 628d22e826
commit 74cca16e9d
2 changed files with 156 additions and 74 deletions

View File

@@ -0,0 +1,86 @@
import logger from "../lib/logger";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sdk: any = require('node-appwrite');
import { getDatabases, isAIConversationsConfigured, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, APPWRITE_DATABASE_ID } from './appwrite';
let schemaEnsured = false;
export async function ensureAIConversationsSchema() {
if (schemaEnsured) return;
if (!isAIConversationsConfigured()) return;
const db = getDatabases();
if (!db) return;
const databaseId = APPWRITE_DATABASE_ID;
const collectionId = APPWRITE_COLLECTION_AI_CONVERSATIONS_ID;
// 1) Asegurar colección
try {
await db.getCollection(databaseId, collectionId);
} catch {
try {
await db.createCollection(
databaseId,
collectionId,
collectionId,
undefined, // permissions (opcional)
undefined, // documentSecurity (opcional)
false // enabled (opcional)
);
} catch (e) {
// @ts-ignore
logger.warn('No se pudo crear la colección de AI conversations (puede existir ya):', e);
}
}
// 2) Atributos requeridos
const createIfMissing = async (fn: () => Promise<any>) => {
try { await fn(); } catch (e: any) {
const msg = String(e?.message || e);
if (!/already exists|attribute_already_exists/i.test(msg)) {
// @ts-ignore
logger.warn('No se pudo crear atributo de AI conversations:', msg);
}
}
};
// Claves y metadatos
await createIfMissing(() => db.createStringAttribute(databaseId, collectionId, 'userId', 64, true));
await createIfMissing(() => db.createStringAttribute(databaseId, collectionId, 'guildId', 64, false));
await createIfMissing(() => db.createStringAttribute(databaseId, collectionId, 'channelId', 64, false));
await createIfMissing(() => db.createStringAttribute(databaseId, collectionId, 'conversationId', 64, true));
await createIfMissing(() => db.createDatetimeAttribute(databaseId, collectionId, 'lastActivity', true));
await createIfMissing(() => db.createDatetimeAttribute(databaseId, collectionId, 'createdAt', true));
// Historial de mensajes serializado como JSON (string grande)
// Nota: El límite exacto soportado puede variar por versión; 32768 suele ser seguro.
await createIfMissing(() => db.createStringAttribute(databaseId, collectionId, 'messagesJson', 32768, false));
// 3) Índices útiles
try {
// Índice compuesto para búsquedas por usuario/guild/canal
// En Appwrite, los índices de tipo 'key' aceptan múltiples atributos y órdenes paralelos
// @ts-ignore
await db.createIndex(databaseId, collectionId, 'idx_user_guild_channel', 'key', ['userId','guildId','channelId'], ['asc','asc','asc']);
} catch (e: any) {
const msg = String(e?.message || e);
if (!/already exists|index_already_exists/i.test(msg)) {
// @ts-ignore
logger.warn('No se pudo crear índice user/guild/channel:', msg);
}
}
try {
// Índice por lastActivity descendente para obtener la más reciente
// @ts-ignore
await db.createIndex(databaseId, collectionId, 'idx_lastActivity_desc', 'key', ['lastActivity'], ['desc']);
} catch (e: any) {
const msg = String(e?.message || e);
if (!/already exists|index_already_exists/i.test(msg)) {
// @ts-ignore
logger.warn('No se pudo crear índice lastActivity:', msg);
}
}
schemaEnsured = true;
}

View File

@@ -4,6 +4,10 @@ import logger from "../lib/logger";
import { Collection } from "discord.js"; import { Collection } from "discord.js";
import { prisma } from "../database/prisma"; import { prisma } from "../database/prisma";
import { getDatabases, APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, isAIConversationsConfigured } from "../api/appwrite"; import { getDatabases, APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, isAIConversationsConfigured } from "../api/appwrite";
import { ensureAIConversationsSchema } from "../api/aiConversationsSchema";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sdk: any = require('node-appwrite');
// Tipos mejorados para mejor type safety // Tipos mejorados para mejor type safety
interface ConversationContext { interface ConversationContext {
@@ -40,19 +44,14 @@ interface AIRequest {
} }
interface AppwriteConversation { interface AppwriteConversation {
$id?: string;
userId: string; userId: string;
guildId?: string; guildId?: string | null;
channelId?: string; channelId?: string | null;
conversationId: string; conversationId: string;
messages: Array<{ messagesJson?: string; // JSON serializado del historial
role: 'user' | 'assistant'; lastActivity: string; // ISO
content: string; createdAt: string; // ISO
timestamp: number;
messageId?: string;
referencedMessageId?: string;
}>;
lastActivity: number;
createdAt: number;
} }
// Utility function para manejar errores de forma type-safe // Utility function para manejar errores de forma type-safe
@@ -151,8 +150,15 @@ export class AIService {
return null; return null;
} }
// Lista de candidatos de modelos de imagen ordenados por preferencia (actualizada + retrocompatibilidad) // Lista de candidatos de modelos de imagen ordenados por preferencia (Imagen 4.0 primero, con retrocompatibilidad)
const candidates = [ const candidates = [
'models/imagen-4.0-generate-001',
'imagen-4.0-generate-001',
'models/imagen-3.0-fast',
'imagen-3.0-fast',
'models/imagen-3.0',
'imagen-3.0',
'models/gemini-2.5-flash-image',
'gemini-2.5-flash-image', 'gemini-2.5-flash-image',
]; ];
@@ -986,57 +992,54 @@ Responde de forma directa y útil:`;
} }
try { try {
await ensureAIConversationsSchema();
const databases = getDatabases(); const databases = getDatabases();
if (!databases) return; if (!databases) return;
// Generar un ID válido para Appwrite (máximo 36 caracteres, solo a-z, A-Z, 0-9, ., -, _) // Asegurar conversationId válido y corto para Appwrite
let conversationId = context.conversationId; let conversationId = context.conversationId;
if (!conversationId) { if (!conversationId) {
// Crear un ID más corto y válido const userIdShort = context.userId.slice(-8);
const userIdShort = context.userId.slice(-8); // Últimos 8 caracteres del userId
const guildIdShort = context.guildId ? context.guildId.slice(-8) : 'dm'; const guildIdShort = context.guildId ? context.guildId.slice(-8) : 'dm';
const timestamp = Date.now().toString(36); // Base36 para hacer más corto const timestamp = Date.now().toString(36);
conversationId = `ai_${userIdShort}_${guildIdShort}_${timestamp}`; conversationId = `ai_${userIdShort}_${guildIdShort}_${timestamp}`.slice(0, 36);
// Asegurar que no exceda 36 caracteres
if (conversationId.length > 36) {
conversationId = conversationId.slice(0, 36);
}
context.conversationId = conversationId; context.conversationId = conversationId;
} }
const appwriteData: AppwriteConversation = { // Serializar mensajes a JSON
const messagesPayload = context.messages.map(m => ({
role: m.role,
content: m.content,
timestamp: m.timestamp,
messageId: m.messageId,
referencedMessageId: m.referencedMessageId,
}));
const messagesJson = JSON.stringify(messagesPayload);
const data: AppwriteConversation = {
userId: context.userId, userId: context.userId,
guildId: context.guildId, guildId: context.guildId ?? null,
channelId: context.channelId, channelId: context.channelId ?? null,
conversationId, conversationId,
messages: context.messages.map(msg => ({ messagesJson,
role: msg.role, lastActivity: new Date(context.lastActivity).toISOString(),
content: msg.content, createdAt: new Date().toISOString(),
timestamp: msg.timestamp,
messageId: msg.messageId,
referencedMessageId: msg.referencedMessageId
})),
lastActivity: context.lastActivity,
createdAt: Date.now()
}; };
// Usar upsert para actualizar si ya existe // Upsert por ID estable
try { try {
await databases.updateDocument( await databases.updateDocument(
APPWRITE_DATABASE_ID, APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID,
conversationId, conversationId,
appwriteData data
); );
} catch (updateError) { } catch (updateError) {
// Si no existe, crearlo
await databases.createDocument( await databases.createDocument(
APPWRITE_DATABASE_ID, APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID,
conversationId, conversationId,
appwriteData data
); );
} }
@@ -1055,56 +1058,49 @@ Responde de forma directa y útil:`;
} }
try { try {
await ensureAIConversationsSchema();
const databases = getDatabases(); const databases = getDatabases();
if (!databases) return null; if (!databases) return null;
// Construir queries válidas para Appwrite const queries: any[] = [sdk.Query.equal('userId', userId)];
const queries = []; if (guildId) queries.push(sdk.Query.equal('guildId', guildId));
if (channelId) queries.push(sdk.Query.equal('channelId', channelId));
queries.push(sdk.Query.orderDesc('lastActivity'));
queries.push(sdk.Query.limit(1));
// Query por userId (siempre requerido)
queries.push(`userId="${userId}"`);
// Query por guildId si existe
if (guildId) {
queries.push(`guildId="${guildId}"`);
}
// Query por channelId si existe
if (channelId) {
queries.push(`channelId="${channelId}"`);
}
// Buscar conversaciones recientes del usuario
const response = await databases.listDocuments( const response = await databases.listDocuments(
APPWRITE_DATABASE_ID, APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID,
queries queries
); ) as unknown as { documents: AppwriteConversation[] };
if (response.documents.length === 0) { const docs = (response?.documents || []) as AppwriteConversation[];
return null; if (!docs.length) return null;
}
// Obtener la conversación más reciente const latest = docs[0];
const latestDoc = response.documents.sort((a: any, b: any) => (b.lastActivity || 0) - (a.lastActivity || 0))[0]; const messagesArray: any[] = (() => {
const data = latestDoc as any as AppwriteConversation; try { return latest.messagesJson ? JSON.parse(latest.messagesJson) : []; } catch { return []; }
})();
// Crear contexto desde los datos de Appwrite
const context: ConversationContext = { const context: ConversationContext = {
messages: (data.messages || []).map(msg => ({ messages: messagesArray.map((msg: any) => ({
...msg, role: msg.role === 'assistant' ? 'assistant' : 'user',
tokens: this.estimateTokens(msg.content) content: String(msg.content || ''),
timestamp: Number(msg.timestamp || Date.now()),
tokens: this.estimateTokens(String(msg.content || '')),
messageId: msg.messageId,
referencedMessageId: msg.referencedMessageId,
})), })),
totalTokens: (data.messages || []).reduce((sum, msg) => sum + this.estimateTokens(msg.content), 0), totalTokens: messagesArray.reduce((sum: number, m: any) => sum + this.estimateTokens(String(m.content || '')), 0),
imageRequests: 0, // Resetear conteo de imágenes imageRequests: 0,
lastActivity: data.lastActivity || Date.now(), lastActivity: Date.parse(latest.lastActivity || new Date().toISOString()) || Date.now(),
userId: data.userId, userId: latest.userId,
guildId: data.guildId, guildId: latest.guildId || undefined,
channelId: data.channelId, channelId: latest.channelId || undefined,
conversationId: data.conversationId conversationId: latest.conversationId,
}; };
logger.debug(`Conversación cargada desde Appwrite: ${data.conversationId}`); logger.debug(`Conversación cargada desde Appwrite: ${latest.conversationId}`);
return context; return context;
} catch (error) { } catch (error) {
logger.warn(`Error cargando conversación desde Appwrite: ${getErrorMessage(error)}`); logger.warn(`Error cargando conversación desde Appwrite: ${getErrorMessage(error)}`);