add bot wsp

This commit is contained in:
Valentin Romero
2026-06-30 22:04:56 -03:00
parent 6859b89262
commit 25d4ba6cc0
166 changed files with 29830 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export class CreateIaDto {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateIaDto } from './create-ia.dto';
export class UpdateIaDto extends PartialType(CreateIaDto) {}
+1
View File
@@ -0,0 +1 @@
export class Ia {}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IaController } from './ia.controller';
import { IaService } from './ia.service';
describe('IaController', () => {
let controller: IaController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IaController],
providers: [IaService],
}).compile();
controller = module.get<IaController>(IaController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
// import { IaService } from './ia.service';
import { CreateIaDto } from './dto/create-ia.dto';
import { UpdateIaDto } from './dto/update-ia.dto';
@Controller('ia')
export class IaController {
constructor() {}
// @Post()
// create(@Body() createIaDto: CreateIaDto) {
// return this.iaService.create(createIaDto);
// }
// @Get()
// findAll() {
// return this.iaService.findAll();
// }
// @Get(':id')
// findOne(@Param('id') id: string) {
// return this.iaService.findOne(+id);
// }
// @Patch(':id')
// update(@Param('id') id: string, @Body() updateIaDto: UpdateIaDto) {
// return this.iaService.update(+id, updateIaDto);
// }
// @Delete(':id')
// remove(@Param('id') id: string) {
// return this.iaService.remove(+id);
// }
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
// import { IaService } from './ia.service';
import { IaController } from './ia.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { HttpModule } from '@nestjs/axios';
import { IaSubscriptionService } from './services/ia.subscription.service';
@Module({
controllers: [IaController],
providers: [
// // IaService,
IaSubscriptionService],
imports: [PrismaModule, HttpModule],
exports: [
// // IaService,
IaSubscriptionService],
})
export class IaModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IaService } from './ia.service';
describe('IaService', () => {
let service: IaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [IaService],
}).compile();
service = module.get<IaService>(IaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+498
View File
@@ -0,0 +1,498 @@
// import { get_encoding, Tiktoken } from '@dqbd/tiktoken';
// import { HttpService } from '@nestjs/axios';
// import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
// import { Prisma, UserRole } from '@prisma/client';
// import dayjs from 'dayjs';
// import { firstValueFrom } from 'rxjs';
// import { PrismaService } from '../prisma/prisma.service';
// import { buildPromptInferAction } from './prompts/buildPromptInferAction';
// import { SessionsService } from '../sessions/sessions.service';
// import { UserDataUpdate } from '../user/user.dto';
// export type UserIntentEnum =
// | "payment_status"
// | "view_enrolled_classes"
// | "view_new_keys"
// | "enroll_new_classes";
// /** Intents permitidos por rol (ajusta si querés diferenciar más) */
// const ROLE_ALLOWED: Record<UserRole, ReadonlyArray<UserIntentEnum>> = {
// [UserRole.ADMIN]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// [UserRole.INSTRUCTOR]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// [UserRole.USER]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// // Invitado: no puede ver pagos ni “mis clases”
// [UserRole.GUEST]: [],
// };
// /** Normaliza texto para matching robusto */
// const norm = (s: string) =>
// s
// .normalize("NFKD")
// .replace(/\p{Diacritic}/gu, "")
// .toLowerCase()
// .trim();
// /** Sinónimos rápidos por intent (ES + algo de EN) */
// const KEYWORDS: Record<UserIntentEnum, RegExp[]> = {
// payment_status: [
// /pago(s)?\b/,
// /\bestado.*pago/,
// /\bestoy.*al dia/,
// /\bal dia\b/,
// /factur(a|as)|cuota|vencim(iento|entos)/,
// /\bpagar\b|\bpague\b/,
// /payment|paid|invoice|bill|due/,
// ],
// view_enrolled_classes: [
// /mis?\s+clases/,
// /clases?.*inscrit[oa]s?/,
// /inscripc(ion|iones)\s+vigentes?/,
// /\bver\s+clases\b|\bmis\s+cursos?\b/,
// /matriculad[oa]s?/,
// /enrolled|my\s+classes|my\s+courses/,
// ],
// view_new_keys: [
// /claves?\s+(nuevas?|disponibles?)/,
// /\bnuevas?\s+claves?\b/,
// /\bcodigos?\b|\baccesos?\b|\bkeys?\b/,
// /generate.*key|new.*key/,
// ],
// enroll_new_classes: [
// /inscrib(irme|ir|irnos)|anotar(me)?|apuntar(me)?|registrar(me)?/,
// /inscripcion\s+nuev(a|as)|nuevas?\s+clases/,
// /\bquiero\s+(sumarme|anotarme|inscribirme)\b/,
// /enroll|sign\s*up|register.*class/,
// ],
// };
// /** Heurística: primer intent cuyo regex matchee */
// const quickHeuristic = (text: string): UserIntentEnum | null => {
// const t = norm(text);
// for (const intent of Object.keys(KEYWORDS) as UserIntentEnum[]) {
// const found = KEYWORDS[intent].some((rx) => rx.test(t));
// if (found) return intent;
// }
// return null;
// };
// /** Prompt builder acotado por rol */
// export const buildPromptInferUserMenuAction = (
// message: string,
// role: UserRole
// ): string => {
// const allowed = ROLE_ALLOWED[role];
// const labels = allowed
// .map((k) => `"${k}"`)
// .join(", ");
// // Unos mini-ejemplos para anclar
// const examples = [
// { in: "necesito ver si estoy al dia con mis pagos", out: "payment_status" },
// { in: "quiero ver mis clases inscritas", out: "view_enrolled_classes" },
// { in: "tienen nuevas claves de acceso?", out: "view_new_keys" },
// { in: "quiero inscribirme a una clase nueva", out: "enroll_new_classes" },
// ]
// // filtra ejemplos que no estén permitidos al rol para no confundir al modelo
// .filter((e) => (allowed as string[]).includes(e.out))
// .map((e) => `Entrada: "${e.in}"\nRespuesta: "${e.out}"`)
// .join("\n\n");
// return `
// Eres un asistente que clasifica la intención del usuario en una de las siguientes opciones PERMITIDAS según su rol (${role}):
// ${labels}
// Analiza el siguiente mensaje y responde SOLO con una de esas claves exactas (sin texto adicional).
// Si la intención NO coincide con ninguna de las opciones permitidas, responde exactamente "null".
// ${examples ? `\nEjemplos:\n\n${examples}\n` : ""}
// Entrada: "${message}"
// Respuesta:
// `.trim();
// };
// /** Timeout helper: evita congelar la UX si la IA tarda */
// const withTimeout = async <T>(
// p: Promise<T>,
// ms = 1200
// ): Promise<T> =>
// Promise.race([
// p,
// new Promise<T>((_, rej) => setTimeout(() => rej(new Error("IA_TIMEOUT")), ms)),
// ]) as Promise<T>;
// /** Mapea string → intent válido o null */
// const parseModelLabel = (
// raw: string | null | undefined,
// allowed: ReadonlyArray<UserIntentEnum>
// ): UserIntentEnum | null => {
// if (!raw) return null;
// const cleaned = raw.replace(/[\s"'`]/g, "").toLowerCase();
// if (cleaned === "null") return null;
// // Intent labels exactos
// const candidates: UserIntentEnum[] = [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ];
// const match = candidates.find((c) => c === cleaned);
// if (!match) return null;
// // Respeta permisos por rol
// return allowed.includes(match) ? match : null;
// };
// export enum ActionsAdmin {
// CREATE_SESSION = 'create_session',
// GET_SESSIONS = 'get_sessions',
// UPDATE_USER = 'update_user',
// GET_USERS = 'get_users',
// }
// //xport type SessionWithSchedules = Partial<Prisma.SessionGetPayload<{ include: { schedules: true } }>>
// @Injectable()
// export class IaService implements OnModuleInit {
// constructor(
// private readonly httpService: HttpService,
// private prisma: PrismaService,
// ) { }
// private readonly logger = new Logger(IaService.name);
// private readonly apiUrl = 'http://localhost:5000/v1/completions';
// //@ts-ignores
// private tokenizer: Tiktoken;
// private readonly ctxSize = 4096; // tamaño de contexto que configuraste en el modelo
// private readonly maxTokens = 512; // tokens máximos que le permitimos al modelo generar
// private readonly safetyMargin = 10; // margen de seguridad
// onModuleInit() {
// this.tokenizer = get_encoding('cl100k_base');
// }
// /** Implementa la llamada real a tu LLM/endpoint */
// private async callModel(prompt: string): Promise<string> {
// // TODO: reemplaza por tu cliente real (OpenAI, local, etc.)
// // Debe devolver el texto "payment_status" | ... | "null"
// throw new Error("callModelNot implemented");
// }
// async detectIntent(
// text: string,
// role: UserRole
// ): Promise<UserIntentEnum | null> {
// const allowed = ROLE_ALLOWED[role];
// // 1) Heurística rápida (barata y rápida)
// const byHeuristic = quickHeuristic(text);
// if (byHeuristic && allowed.includes(byHeuristic)) {
// return byHeuristic;
// }
// // 2) LLM (con timeout) usando prompt limitado por rol
// try {
// const prompt = buildPromptInferUserMenuAction(text, role);
// const raw = await withTimeout(this.callModel(prompt), 1500) as string | null;
// const intent = parseModelLabel(raw, allowed);
// if (intent) return intent;
// } catch {
// // caemos a null si hay timeout/errores
// }
// // 3) Nada concluyente
// return null;
// }
// async getActionAdmin(input: string): Promise<ActionsAdmin> {
// try {
// const prompt = buildPromptInferAction(input);
// const response = await this.executePrompt<string>(prompt)
// if (!Object.values(ActionsAdmin).includes(response as ActionsAdmin)) {
// this.logger.error(`Acción no válida: ${response}`);
// throw new Error('La acción inferida no es válida.');
// }
// return response as ActionsAdmin;
// }
// catch (error: any) {
// this.logger.error('Error al obtener la acción de administrador', error);
// throw new Error('No se pudo obtener la acción de administrador.');
// }
// }
// async getUserUpdatePayload(input: string): Promise<UserDataUpdate> {
// try {
// const prompt = this.buildPromptUpdateUser(input);
// const promptTokens = this.countTokens(prompt);
// // Validación: aseguramos que no exceda el tamaño de contexto
// if (promptTokens + this.maxTokens + this.safetyMargin > this.ctxSize) {
// this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// throw new Error('El prompt es demasiado largo para el contexto disponible.');
// }
// const { data } = await firstValueFrom(
// this.httpService.post(this.apiUrl, {
// prompt,
// max_tokens: 256,
// temperature: 0.2,
// stop: ['Entrada:', '\n\n'],
// }),
// );
// const { choices } = data;
// const choice = choices[0];
// if (choice.finish_reason !== 'stop') {
// throw new Error('La respuesta fue incompleta o abortada.');
// }
// const responseText = choice.text.trim();
// // Parseamos el JSON que viene como string
// const jsonResponse = JSON.parse(responseText);
// const response: UserDataUpdate = {
// customId: jsonResponse.customId,
// name: jsonResponse.name,
// phone: jsonResponse.phone.replace(/\D/g, ''), // Eliminamos todo lo que no sea dígito
// birth: dayjs(jsonResponse.birth).toDate(),
// }
// return response;
// }
// catch (error: any) {
// this.logger.error('Error al obtener el payload de actualización del usuario', error);
// throw new Error('No se pudo obtener el payload de actualización del usuario.');
// }
// }
// //TODO cambiarle el nombre , lo que hace es parsear de natural a json
// // async createSession(input: string): Promise<SessionWithSchedules> {
// // const prompt = this.buildPromptCreateSession(input);
// // const promptTokens = this.countTokens(prompt);
// // // Validación: aseguramos que no exceda el tamaño de contexto
// // if (promptTokens + this.maxTokens + this.safetyMargin > this.ctxSize) {
// // this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// // throw new Error('El prompt es demasiado largo para el contexto disponible.');
// // }
// // try {
// // const { data } = await firstValueFrom(
// // this.httpService.post(this.apiUrl, {
// // prompt,
// // max_tokens: 512,
// // temperature: 0.2,
// // stop: ['\n\n', 'Entrada:'],
// // }),
// // );
// // const { choices } = data;
// // const choice = choices[0];
// // if (choice.finish_reason !== 'stop') {
// // throw new Error('La respuesta fue incompleta o abortada.');
// // }
// // const responseText = choice.text.trim();
// // // Parseamos el JSON que viene como string
// // const jsonResponse = JSON.parse(responseText);
// // const response: SessionWithSchedules = {
// // description: jsonResponse.description,
// // type: jsonResponse.type,
// // schedules: jsonResponse?.schedules?.map((item: any) => {
// // return {
// // dayOfWeek: item.dayOfWeek || null,
// // specificDate: item.specificDate ? dayjs(item.specificDate).toDate() : null,
// // startTime: dayjs(item.startDateTime).toDate(),
// // endTime: dayjs(item.endDateTime).toDate(),
// // }
// // }),
// // }
// // return response;
// // } catch (error) {
// // this.logger.error('Error al generar la sesión', error);
// // throw new Error('No se pudo generar la sesión.');
// // }
// // }
// private countTokens(text: string): number {
// const tokens = this.tokenizer.encode(text);
// return tokens.length;
// }
// async executePrompt<T>(
// prompt: string,
// options?: {
// maxTokens?: number;
// temperature?: number;
// stop?: string[];
// }
// ): Promise<T> {
// const promptTokens = this.countTokens(prompt);
// // Configuración por defecto
// const maxTokens = options?.maxTokens ?? 256;
// const temperature = options?.temperature ?? 0.2;
// const stop = options?.stop ?? ['Entrada:', '\n\n'];
// if (promptTokens + maxTokens + this.safetyMargin > this.ctxSize) {
// this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// throw new Error('El prompt es demasiado largo para el contexto disponible.');
// }
// const { data } = await firstValueFrom(
// this.httpService.post(this.apiUrl, {
// prompt,
// max_tokens: maxTokens,
// temperature,
// stop,
// }),
// );
// const { choices } = data;
// const choice = choices[0];
// if (choice.finish_reason !== 'stop') {
// throw new Error('La respuesta fue incompleta o abortada.');
// }
// const responseText = choice.text.trim();
// try {
// return JSON.parse(responseText) as T;
// } catch (error) {
// this.logger.error('Error al parsear la respuesta JSON:', error);
// throw new Error('La respuesta no es un JSON válido.');
// }
// }
// private buildPromptCreateSession(message: string): string {
// return `
// Eres un asistente que solo responde en formato JSON válido y nunca agrega texto adicional.
// Devuelve solo la estructura JSON con este formato:
// {
// "description": string,
// "type": "RECURRING" o "ONE_TIME",
// "schedules": [
// {
// "dayOfWeek": string o null,
// "specificDate": string o null //en formato DD-MM-YYYY HH-mm ,
// "startDateTime": string //en formato DD-MM-YYYY HH-mm,
// "endDateTime": string //en formato DD-MM-YYYY HH-mm
// }
// ]
// }
// Reglas importantes:
// - Extrae siempre la fecha y hora que aparecen escritas en el mensaje. No inventes, no uses la fecha ni la hora actuales.
// - Si el mensaje contiene una fecha específica (por ejemplo, "2/7/2025"), úsala exactamente como aparece para los campos "specificDate", "startDateTime" y "endDateTime".
// - Si el evento es recurrente, usa palabras clave como "recurrente", "todos los", "cada", "se repite" para detectarlo. En ese caso, el campo "specificDate" debe ser null, "dayOfWeek" debe tener el nombre del día en inglés, y las horas deben colocarse con una fecha ficticia: "1970-01-01".
// - Si el evento es puntual, el campo "dayOfWeek" debe ser null y "specificDate" debe ser la fecha real extraída del mensaje.
// - Convierte siempre las horas al formato de 24 horas y en formato ISO: yyyy-mm-ddTHH:MM:SSZ.
// - Si el mensaje tiene un horario como "11:00AM - 14:00PM", interpreta que:
// - "11:00AM" es la hora de inicio
// - "14:00PM" es la hora de fin (corrige a formato 24h si es necesario)
// Ejemplos:
// Entrada: "Clase 'Zumba' 15/12/2025 de 8:30am a 10:00am"
// Respuesta:
// {
// "description": "Zumba",
// "type": "ONE_TIME",
// "schedules": [
// {
// "dayOfWeek": null,
// "specificDate": "2025-12-15T08:30:00Z",
// "startDateTime": "2025-12-15T08:30:00Z",
// "endDateTime": "2025-12-15T10:00:00Z"
// }
// ]
// }
// Entrada: "Clase Yoga recurrente lunes de 8:30am a 10:00am"
// Respuesta:
// {
// "description": "Yoga",
// "type": "RECURRING",
// "schedules": [
// {
// "dayOfWeek": "Monday",
// "specificDate": null,
// "startDateTime": "1970-01-01T08:30:00Z",
// "endDateTime": "1970-01-01T10:00:00Z"
// }
// ]
// }
// Entrada: ${message}
// Respuesta:
// `.trim();
// }
// private buildPromptUpdateUser(message: string): string {
// return `
// Eres un asistente que responde únicamente en formato JSON válido. No agregues texto adicional ni explicaciones.
// Formato esperado:
// {
// "customId": string, // Ejemplo: "U-001"
// "name": string, // Nombre completo
// "phone": string, // Solo dígitos, sin espacios ni símbolos
// "birth": string // Fecha en formato ISO: YYYY-MM-DD
// }
// Reglas:
// - El primer token siempre es el customId (ejemplo: U-001).
// - El nombre puede incluir espacios y letras, ignora palabras como "nombre".
// - El teléfono puede venir con espacios o símbolos, pero debes devolver solo los dígitos.
// - La fecha de nacimiento puede venir como DD/MM/YYYY o D/M/YYYY y debes convertirla a formato YYYY-MM-DD.
// Ejemplo:
// Entrada:
// U-001 nombre Leandro Leones 3435077510 18/12/1995
// Salida:
// {
// "customId": "U-001",
// "name": "Leandro Leones",
// "phone": "3435077510",
// "birth": "1995-12-18"
// }
// Entrada:
// ${message}
// Salida:
// `.trim();
// }
// }
@@ -0,0 +1,42 @@
export const buildPromptGetUser = (message: string): string => {
return `
Eres un asistente que interpreta mensajes para buscar usuarios en la base de datos.
Analiza el siguiente mensaje y responde solo con un JSON así:
{
"nombre": string | null, // nombre o parte del nombre del usuario a buscar, o null si no se especifica
"birthdayMonth": number | null // número del mes de cumpleaños del usuario (1 = enero, 12 = diciembre), o null si no se especifica
}
Reglas:
- Si el usuario menciona un nombre o parte del nombre, debes colocar ese valor en "nombre".
- Si el usuario menciona un mes de cumpleaños (por ejemplo "cumpleaños en mayo"), debes colocar el número correspondiente en "birthdayMonth".
- Si no se especifica un dato, debes ponerlo como null.
Ejemplos:
Entrada: "Buscar usuarios que se llaman Juan y cumplen en mayo"
Respuesta:
{
"nombre": "Juan",
"birthdayMonth": 5
}
Entrada: "Buscar usuarios que cumplen en noviembre"
Respuesta:
{
"nombre": null,
"birthdayMonth": 11
}
Entrada: "Buscar usuarios llamados María"
Respuesta:
{
"nombre": "María",
"birthdayMonth": null
}
Entrada: ${message}
Respuesta:
`.trim();
};
@@ -0,0 +1,27 @@
export const buildPromptInferAction = (message: string): string => {
return `
Eres un asistente que clasifica la intención de un usuario.
Analiza el siguiente mensaje y devuelve solo una de las siguientes claves JSON válidas según la intención detectada:
- "create_session" si el usuario quiere crear una sesión, agendar, programar, registrar una clase o similar.
- "update_user" si el usuario quiere actualizar personales, cambiar nombre, cambiar teléfono, modificar información de usuario.
- "get_sessions" si el usuario quiere ver las sesiones, clases, horarios, agenda, calendario o similar.
- "get_users" si el usuario quiere ver o listar usuarios, personas, alumnos, estudiantes o similar.
Solo responde la clave exacta sin agregar texto adicional.
Ejemplo:
Entrada: "Quiero crear una nueva clase para mañana"
Respuesta: "create_session"
Entrada: "Necesito actualizar el telefono del usuario U-001"
Respuesta: "update_user"
Entrada: "Quiero ver las clases para mañana"
Respuesta: "get_sessions"
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,46 @@
export const buildPromptGetSession = (message: string): string => {
return `
Eres un asistente que interpreta mensajes para buscar sesiones en la base de datos.
Analiza el siguiente mensaje y responde solo con un JSON así:
{
"modo": "filtrar" | "proxima", // "filtrar" si el usuario quiere buscar clases específicas, "proxima" si solo quiere saber cuál es la próxima clase sin filtros
"descripcion": string | null, // palabra clave (ej: yoga), o null
"instructor": string | null, // nombre del instructor, o null
"assistant": string | null, // nombre del asistente, o null
"temporalReference": string | null, // frase sobre fecha u hora, sin convertir. Ej: "lunes siguiente a las 11 AM"
"daysOfWeek": string[] // array con días de la semana en español, puede estar vacío
}
Reglas:
- Si el usuario pregunta por "la próxima clase", "la siguiente clase", o algo similar, debes responder con modo: "proxima" y dejar los otros campos en null o vacío.
- Si el usuario busca por nombre de clase, día o instructor, responde con modo: "filtrar" y llena los campos correspondientes.
Ejemplos:
Entrada: "Dame las clases del lunes siguiente a las 11 AM de yoga con el profesor Juan"
Respuesta:
{
"modo": "filtrar",
"descripcion": "yoga",
"instructor": "Juan",
"assistant": null,
"temporalReference": "lunes siguiente a las 11 AM",
"daysOfWeek": ["LUNES"]
}
Entrada: "¿Cuál es la próxima clase?"
Respuesta:
{
"modo": "proxima",
"descripcion": null,
"instructor": null,
"assistant": null,
"temporalReference": null,
"daysOfWeek": []
}
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,45 @@
export const builderSubscriptionPrompt = (message: string): string => {
return `
Eres un asistente que solo responde en formato JSON válido y nunca agrega texto adicional.
Debes inferir y devolver solo la estructura JSON con este formato:
{
"user": {
"name": string,
"customId": string | null
},
"product": {
"description": string,
"customId": string | null
},
"discount": number | null,
"mode": "assistant" | "instructor"
}
Reglas:
- El "customId" es un string con el formato: una letra, seguida de un guion y tres caracteres (ejemplo: A-123). Si no está presente, devolver null.
- El "discount" debe ser un número si el mensaje hace referencia a un descuento, caso contrario devolver null.
- El "mode" debe ser "assistant" por defecto. Si el mensaje menciona que el usuario es un profesor o instructor, el modo debe ser "instructor".
- El nombre debe inferirse como el nombre completo de la persona mencionada.
- La descripción debe inferirse como el nombre de la clase o producto mencionado.
Ejemplo:
Entrada: "Agregar a valentin romero a la clase de latino con un descuento del 30%"
Respuesta:
{
"user": {
"name": "valentin romero",
"customId": null
},
"product": {
"description": "latino",
"customId": null
},
"discount": 30,
"mode": "assistant"
}
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,255 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { Session, User, Prisma, } from '@prisma/client';
import { firstValueFrom, lastValueFrom } from 'rxjs';
import { PrismaService } from '../../prisma/prisma.service';
// import { IaService } from '../ia.service';
import { link } from 'fs';
import { builderSubscriptionPrompt } from './builderSubscriptionPrompt';
type SubscriptionWithDataInvoices = {
id: string;
userId: string;
sessionId: string;
base64: string;
linkPayment: string;
}
type CreateInvoiceOdoo = {
user: {
userCode: string;
dni?: string;
}
product: {
description: string;
productCode: string;
amount: number;
}
}
type ResponseInvoiceOdoo = {
linkPayment: string;
linkInvoice: string; // URL de la factura
invoice: string // base 64
}
type ResponseSubscribeFromNaturalMessage = {
message: string;
invoice64: string;
linkPayment: string;
}
type ProductsModels = Session
@Injectable()
export class IaSubscriptionService {
private readonly logger = new Logger(IaSubscriptionService.name);
constructor(
// private iaService: any, // Asegúrate de importar IaService correctamente
private readonly prisma: PrismaService,
private httpService: HttpService, // Asegúrate de importar HttpService si lo necesitas
) {
this.logger.log('IaSubscriptionService initialized');
}
async subscribeFromNaturalMessage(input: string, esAdmin: boolean = false): Promise<ResponseSubscribeFromNaturalMessage> {
try {
//const a = await this.iaService.
type ResponseIaSubscription = {
user: {
name: string,
customId: string,
},
product: {
description: string,
customId: string,
},
discount?: number,
mode: 'assistant' | 'instructor',
}
const prompt = builderSubscriptionPrompt(input);
const { data } = await firstValueFrom(
this.httpService.post(process.env.URL_IA!, {
prompt,
max_tokens: 256,
temperature: 0.2,
stop: ['Entrada:', '\n\n'],
}),
);
const { choices } = data;
const choice = choices[0];
if (choice.finish_reason !== 'stop') {
throw new Error('La respuesta fue incompleta o abortada.');
}
const responseText = choice.text.trim();
// Parseamos el JSON que viene como string
const jsonResponse = JSON.parse(responseText);
// TODO: Replace this with actual response from IA service
const response = jsonResponse as ResponseIaSubscription;
if (!response) {
throw new Error('No response from IA service');
}
const nameParts = response.user.name?.split(' ').filter(part => part.length >= 3) || [];
if (nameParts.length < 2 && !response.user.customId) {
throw new Error('No se pudo inferir suficiente información para buscar el usuario.');
}
const whereConditions: any = {};
if (response.user.customId) {
whereConditions.customId = response.user.customId;
} else if (nameParts.length >= 2) {
whereConditions.AND = [
{ name: { contains: nameParts[0], mode: 'insensitive' } },
{ name: { contains: nameParts[nameParts.length - 1], mode: 'insensitive' } }
];
}
const user = await this.prisma.user.findMany({
where: whereConditions,
take: 1
});
if (user.length === 0) {
throw new Error('User not found');
} else if (user.length > 1) {
this.logger.warn(`Multiple users found for query: ${JSON.stringify(whereConditions)}. Using the first one.`);
}
const descriptionParts = response.product.description?.split(' ').filter(part => part.length >= 3) || [];
if (descriptionParts.length === 0 && !response.product.customId) {
throw new Error('No se pudo inferir suficiente información para buscar el producto.');
}
const whereProductConditions: any = {};
if (response.product.customId) {
whereProductConditions.customId = response.product.customId;
} else {
whereProductConditions.AND = [
// oj que busca solo por el primer
{ description: { contains: descriptionParts[0], mode: 'insensitive' } },
// { description: { contains: descriptionParts[descriptionParts.length - 1], mode: 'insensitive' } }
];
}
const modelName = 'session'; // Asumiendo que el modelo es Session
const product = await this.prisma?.[modelName].findMany({
where: whereProductConditions
});
if (product.length === 0) {
throw new Error('Producto no encontrado.');
}
if (product.length > 1) {
throw new Error('La búsqueda devolvió múltiples productos. Por favor, proporciona más información para identificarlo correctamente.');
}
if (!user || !product) {
throw new Error('User or product not found');
}
if (!response.mode) {
this.logger.debug(`No mode provided for subscription, defaulting to 'assistant'`);
}
const subscribe = await this.subscribe(user[0], product[0], response.mode);
const responseService = {
message: `User ${user[0].name} subscribed to product ${product[0].description} with mode ${response.mode}. Invoice ID: ${subscribe.id}`,
invoice64: subscribe.base64, // Asumiendo que la factura es un base64
linkPayment: subscribe.linkPayment, // URL de pago
}
return responseService
}
catch (error: any) {
throw new Error(`Failed to subscribe: ${error.message}`);
}
}
// Ejemplo de método para suscribirse
async subscribe(user: User, product: ProductsModels, mode: 'assistant' | 'instructor'): Promise<SubscriptionWithDataInvoices> {
try {
this.logger.log(`User ${user.id} subscribed`);
// Use explicit model access instead of dynamic string indexing
const updateProduct = await this.prisma.session.update({
where: { id: product.id },
data: {
...(mode === 'instructor' ? { instructors: { connect: { id: user.id } } } : {}), // Conectar el usuario como instructor
...(mode === 'assistant' ? { assistants: { connect: { id: user.id } } } : {}), // Conectar el usuario como asistente
}
})
const createInvoiceOdoo: CreateInvoiceOdoo = {
user: {
userCode: user.id,
dni: user.dni || undefined, // Asumiendo que el usuario tiene un campo dni
},
product: {
description: updateProduct.description,
productCode: updateProduct.customId,
amount: updateProduct.amount || 11, // Monto ficticio
}
}
const { data } = await lastValueFrom(
this.httpService.post<ResponseInvoiceOdoo>(
process.env.URL_INVOICE_ODOO!, // URL ficticia del API de Odoo
createInvoiceOdoo as CreateInvoiceOdoo , {
headers: {
'Content-Type': 'application/json',
}}
)
)
//@ts-ignore
const { invoice, linkInvoice, linkPayment } = data['result'] || {}
// Crear factura directamente sin suscripción intermedia
const createdInvoice = await this.prisma.invoice.create({
data: {
userId: user.id,
sessionId: product.id,
amount: product.amount || 1, // Monto ficticio
dateInvoice: new Date(), // Mes actual
base64Invoice: invoice,
linkPayment: linkPayment,
status: 'PENDING'
}
});
// Retornar en formato compatible
return {
id: createdInvoice.id,
userId: user.id,
sessionId: product.id,
base64: invoice,
linkPayment
};
}
catch (error: any) {
throw new Error(`Failed to subscribe user ${user.id}: ${error.message}`);
}
}
// Ejemplo de método para cancelar suscripción
unsubscribe(userId: string): string {
this.logger.log(`User ${userId} unsubscribed`);
return `User ${userId} unsubscribed successfully`;
}
}