feat: implement AI conversations schema management and ensure data integrity in AI service
This commit is contained in:
86
src/core/api/aiConversationsSchema.ts
Normal file
86
src/core/api/aiConversationsSchema.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user