feat(llm): Implement role-based chat for owner and customer

- Add UserRole type and role detection via getUserRole()
- Add OWNER_TOOLS and CUSTOMER_TOOLS permission arrays
- Implement role-based system prompts (owner vs customer)
- Add owner-specific functions: sales_summary, inventory_status, pending_fiados, etc
- Add getBusinessInfo() for tenant business information
- Add isToolAllowed() for permission validation
- Update webhook service with role-aware message handling

Sprint 2: MCH-012 Chat LLM Dueño + MCH-013 Chat LLM Cliente

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 03:08:22 -06:00
parent 44bcabd064
commit 9a8f0cb873
3 changed files with 586 additions and 51 deletions

View File

@ -23,6 +23,75 @@ export interface LLMConfig {
tenantId?: string;
}
export type UserRole = 'owner' | 'customer';
export interface UserRoleInfo {
role: UserRole;
userId?: string;
customerId?: string;
permissions: string[];
}
export interface BusinessInfo {
name: string;
address?: string;
phone?: string;
hours?: {
open: string;
close: string;
days: string;
};
promotions?: Array<{
name: string;
description: string;
discount?: number;
}>;
}
// Tools disponibles por rol
export const OWNER_TOOLS = [
'search_products',
'get_product',
'list_products',
'get_product_by_barcode',
'get_low_stock_products',
'update_product_price',
'create_order',
'get_order',
'list_orders',
'update_order_status',
'create_fiado',
'get_customer_fiados',
'pay_fiado',
'get_pending_fiados',
'search_customers',
'get_customer',
'create_customer',
'get_inventory_status',
'adjust_stock',
'get_stock_movements',
'get_expiring_products',
'get_daily_sales',
'get_sales_report',
'register_sale',
'get_today_summary',
'send_payment_reminder',
'get_business_metrics',
'get_top_products',
];
export const CUSTOMER_TOOLS = [
'search_products',
'get_product',
'list_products',
'check_availability',
'get_product_price',
'create_order',
'get_my_balance',
'get_business_info',
'get_promotions',
];
@Injectable()
export class CredentialsProviderService {
private readonly logger = new Logger(CredentialsProviderService.name);
@ -220,6 +289,100 @@ export class CredentialsProviderService {
this.cache.delete(`llm:${tenantId}`);
}
/**
* Determina el rol del usuario basado en su número de teléfono
* Los dueños están registrados en la tabla de usuarios del tenant
*/
async getUserRole(phoneNumber: string, tenantId?: string): Promise<UserRoleInfo> {
const cacheKey = `role:${phoneNumber}:${tenantId || 'platform'}`;
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
try {
const { data } = await this.backendClient.get(
`/internal/users/role-by-phone/${phoneNumber}`,
{
headers: {
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
...(tenantId && { 'X-Tenant-Id': tenantId }),
},
},
);
const roleInfo: UserRoleInfo = {
role: data.isOwner ? 'owner' : 'customer',
userId: data.userId,
customerId: data.customerId,
permissions: data.isOwner ? OWNER_TOOLS : CUSTOMER_TOOLS,
};
this.setCache(cacheKey, roleInfo);
return roleInfo;
} catch (error) {
this.logger.debug(`Could not determine role for ${phoneNumber}, defaulting to customer`);
// Default to customer role
return {
role: 'customer',
permissions: CUSTOMER_TOOLS,
};
}
}
/**
* Obtiene información del negocio para mostrar a clientes
*/
async getBusinessInfo(tenantId?: string): Promise<BusinessInfo> {
const cacheKey = `business:${tenantId || 'platform'}`;
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
try {
const { data } = await this.backendClient.get(
`/internal/business/info`,
{
headers: {
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
...(tenantId && { 'X-Tenant-Id': tenantId }),
},
},
);
const info: BusinessInfo = {
name: data.name || 'MiChangarrito',
address: data.address,
phone: data.phone,
hours: data.hours,
promotions: data.promotions || [],
};
this.setCache(cacheKey, info);
return info;
} catch (error) {
// Return default info
return {
name: 'MiChangarrito',
hours: {
open: '07:00',
close: '22:00',
days: 'Lunes a Domingo',
},
promotions: [],
};
}
}
/**
* Verifica si un tool está permitido para un rol
*/
isToolAllowed(toolName: string, role: UserRole): boolean {
const allowedTools = role === 'owner' ? OWNER_TOOLS : CUSTOMER_TOOLS;
return allowedTools.includes(toolName);
}
private getFromCache(key: string): any | null {
const entry = this.cache.get(key);
if (entry && entry.expiresAt > Date.now()) {

View File

@ -1,10 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { CredentialsProviderService, LLMConfig } from '../common/credentials-provider.service';
import {
CredentialsProviderService,
LLMConfig,
UserRole,
} from '../common/credentials-provider.service';
interface ConversationContext {
customerId?: string;
userId?: string;
customerName: string;
phoneNumber: string;
lastActivity: Date;
@ -13,6 +18,8 @@ interface ConversationContext {
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
tenantId?: string;
businessName?: string;
role: UserRole;
permissions: string[];
}
interface LlmResponse {
@ -97,8 +104,8 @@ export class LlmService {
history = [history[0], ...history.slice(-20)];
}
// Call LLM with tenant config
const response = await this.callLlm(history, llmConfig);
// Call LLM with tenant config and user role
const response = await this.callLlm(history, llmConfig, context.role);
// Add assistant response to history
history.push({ role: 'assistant', content: response.message });
@ -111,8 +118,8 @@ export class LlmService {
}
}
private async callLlm(messages: ChatMessage[], config: LLMConfig): Promise<LlmResponse> {
const functions = this.getAvailableFunctions();
private async callLlm(messages: ChatMessage[], config: LLMConfig, role: UserRole = 'customer'): Promise<LlmResponse> {
const functions = this.getAvailableFunctions(role);
const client = this.getClient(config);
const requestBody: any = {
@ -163,41 +170,80 @@ export class LlmService {
private getSystemPrompt(context: ConversationContext): string {
const businessName = context.businessName || 'MiChangarrito';
const businessDescription = context.businessName
? `un negocio local`
: `una tiendita de barrio en Mexico`;
return `Eres el asistente virtual de ${businessName}, ${businessDescription}.
if (context.role === 'owner') {
return this.getOwnerSystemPrompt(businessName, context);
} else {
return this.getCustomerSystemPrompt(businessName, context);
}
}
private getOwnerSystemPrompt(businessName: string, context: ConversationContext): string {
return `Eres el asistente virtual de ${businessName} para el DUEÑO del negocio.
Tu nombre es "Asistente de ${businessName}" y ayudas al dueño con:
- Consultar ventas del dia/semana/mes
- Revisar inventario y productos con stock bajo
- Ver fiados pendientes y enviar recordatorios
- Generar reportes de negocio
- Modificar precios de productos
- Obtener metricas y sugerencias
Informacion del dueño:
- Nombre: ${context.customerName}
- Rol: DUEÑO/ADMINISTRADOR
- Acceso: COMPLETO a todas las funciones
Reglas importantes:
1. Responde siempre en español mexicano, de forma profesional pero amigable
2. Usa emojis con moderación para datos importantes
3. Siempre muestra numeros formateados (ej: $1,234.56)
4. Para consultas de ventas, incluye comparativas (vs ayer, vs semana pasada)
5. Para inventario, prioriza productos con stock bajo o proximos a vencer
6. Para fiados, destaca clientes con atrasos mayores a 7 dias
7. Se proactivo sugiriendo acciones basadas en los datos
8. Tienes acceso a modificar precios - siempre pide confirmacion primero
Ejemplos de respuestas:
- "Hoy llevas $3,450 en 23 ventas, +15% vs ayer 📈"
- "Tienes 5 productos con stock bajo, ¿quieres ver la lista?"
- "3 clientes deben mas de $500, ¿envio recordatorios?"`;
}
private getCustomerSystemPrompt(businessName: string, context: ConversationContext): string {
return `Eres el asistente virtual de ${businessName} para CLIENTES.
Tu nombre es "Asistente de ${businessName}" y ayudas a los clientes con:
- Informacion sobre productos y precios
- Hacer pedidos
- Consultar su cuenta de fiado (credito)
- Estado de sus pedidos
- Consultar SU cuenta de fiado (solo la suya)
- Estado de SUS pedidos
Informacion del cliente:
- Nombre: ${context.customerName}
- Rol: CLIENTE
- Tiene carrito con ${context.cart?.length || 0} productos
Reglas importantes:
1. Responde siempre en espanol mexicano, de forma amigable y breve
2. Usa emojis ocasionalmente para ser mas amigable
3. Si el cliente quiere hacer algo especifico, usa las funciones disponibles
4. Si no entiendes algo, pide aclaracion de forma amable
5. Nunca inventes precios o productos, di que consultaras el catalogo
6. Para fiados, siempre verifica primero el saldo disponible
7. Se proactivo sugiriendo opciones relevantes
1. Responde siempre en español mexicano, de forma amigable y breve
2. Usa emojis ocasionalmente para ser mas amigable 😊
3. NUNCA muestres informacion de otros clientes
4. NUNCA muestres datos de ventas, inventario o metricas del negocio
5. Solo puedes mostrar productos, precios, y la cuenta de ESTE cliente
6. Si preguntan algo que no puedes responder, sugiere contactar al dueño
7. Para fiados, solo muestra el saldo de ESTE cliente
8. Se proactivo sugiriendo productos relevantes
Ejemplos de respuestas:
- "Claro! Te muestro el menu de productos"
- "Perfecto, agrego eso a tu carrito"
- "Dejame revisar tu cuenta de fiado..."`;
- "¡Claro! Te muestro el menu de productos"
- "Perfecto, agrego eso a tu carrito 🛒"
- "Tu saldo pendiente es de $180"`;
}
private getAvailableFunctions(): any[] {
return [
private getAvailableFunctions(role: UserRole): any[] {
// Base functions available to all users
const baseFunctions = [
{
name: 'show_menu',
description: 'Muestra el menu principal de opciones al cliente',
description: 'Muestra el menu principal de opciones',
parameters: { type: 'object', properties: {} },
},
{
@ -215,12 +261,12 @@ Ejemplos de respuestas:
},
{
name: 'show_fiado',
description: 'Muestra informacion de la cuenta de fiado del cliente',
description: 'Muestra informacion de la cuenta de fiado',
parameters: { type: 'object', properties: {} },
},
{
name: 'add_to_cart',
description: 'Agrega un producto al carrito del cliente',
description: 'Agrega un producto al carrito',
parameters: {
type: 'object',
properties: {
@ -239,10 +285,72 @@ Ejemplos de respuestas:
},
{
name: 'check_order_status',
description: 'Consulta el estado de los pedidos del cliente',
description: 'Consulta el estado de pedidos',
parameters: { type: 'object', properties: {} },
},
];
// Owner-specific functions (MCH-012)
const ownerFunctions = [
{
name: 'show_sales_summary',
description: 'Muestra resumen de ventas del dia/semana/mes',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['today', 'week', 'month'],
description: 'Periodo de tiempo (hoy, semana, mes)',
},
},
},
},
{
name: 'show_inventory_status',
description: 'Muestra estado del inventario, productos con stock bajo y proximos a vencer',
parameters: { type: 'object', properties: {} },
},
{
name: 'show_pending_fiados',
description: 'Muestra todos los fiados pendientes de cobro',
parameters: { type: 'object', properties: {} },
},
{
name: 'update_product_price',
description: 'Actualiza el precio de un producto',
parameters: {
type: 'object',
properties: {
product_name: {
type: 'string',
description: 'Nombre del producto',
},
new_price: {
type: 'number',
description: 'Nuevo precio',
},
},
required: ['product_name', 'new_price'],
},
},
{
name: 'send_payment_reminders',
description: 'Envia recordatorios de pago a clientes con fiados atrasados',
parameters: { type: 'object', properties: {} },
},
{
name: 'get_business_metrics',
description: 'Obtiene metricas del negocio: margen, cliente top, producto top',
parameters: { type: 'object', properties: {} },
},
];
if (role === 'owner') {
return [...baseFunctions, ...ownerFunctions];
}
return baseFunctions;
}
private getActionMessage(action: string): string {

View File

@ -2,7 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
import { LlmService } from '../llm/llm.service';
import { CredentialsProviderService } from '../common/credentials-provider.service';
import {
CredentialsProviderService,
UserRole,
UserRoleInfo,
} from '../common/credentials-provider.service';
import {
WebhookIncomingMessage,
WebhookContact,
@ -11,6 +15,7 @@ import {
interface ConversationContext {
customerId?: string;
userId?: string;
customerName: string;
phoneNumber: string;
lastActivity: Date;
@ -19,6 +24,9 @@ interface ConversationContext {
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
tenantId?: string;
businessName?: string;
// Role-based access control
role: UserRole;
permissions: string[];
}
@Injectable()
@ -61,7 +69,14 @@ export class WebhookService {
const tenantId = await this.credentialsProvider.resolveTenantFromPhoneNumberId(phoneNumberId);
const source = tenantId ? `tenant:${tenantId}` : 'platform';
this.logger.log(`Processing message from ${customerName} (${phoneNumber}) via ${source}: ${message.type}`);
// Determine user role (owner vs customer)
const roleInfo = await this.credentialsProvider.getUserRole(phoneNumber, tenantId);
const roleLabel = roleInfo.role === 'owner' ? 'OWNER' : 'CUSTOMER';
this.logger.log(`Processing message from ${customerName} (${phoneNumber}) [${roleLabel}] via ${source}: ${message.type}`);
// Get business info for context
const businessInfo = await this.credentialsProvider.getBusinessInfo(tenantId);
// Get or create conversation context
let context = this.conversations.get(phoneNumber);
@ -72,14 +87,20 @@ export class WebhookService {
lastActivity: new Date(),
cart: [],
tenantId,
// TODO: Fetch business name from tenant if available
businessName: tenantId ? undefined : 'MiChangarrito',
businessName: businessInfo.name,
role: roleInfo.role,
permissions: roleInfo.permissions,
userId: roleInfo.userId,
customerId: roleInfo.customerId,
};
this.conversations.set(phoneNumber, context);
}
context.lastActivity = new Date();
// Update tenantId in case it changed (shouldn't normally happen)
// Update tenantId and role in case they changed
context.tenantId = tenantId;
context.role = roleInfo.role;
context.permissions = roleInfo.permissions;
context.businessName = businessInfo.name;
try {
switch (message.type) {
@ -230,9 +251,10 @@ export class WebhookService {
buttonId: string,
context: ConversationContext,
): Promise<void> {
this.logger.log(`Button response: ${buttonId}`);
this.logger.log(`Button response: ${buttonId} [role: ${context.role}]`);
switch (buttonId) {
// Customer buttons
case 'menu_products':
await this.sendProductCategories(phoneNumber, context);
break;
@ -261,6 +283,48 @@ export class WebhookService {
await this.cancelOrder(phoneNumber, context);
break;
// Owner-specific buttons (MCH-012)
case 'owner_sales':
if (context.role !== 'owner') {
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Esta opción solo está disponible para el dueño del negocio.',
context.tenantId,
);
return;
}
await this.sendOwnerSalesSummary(phoneNumber, context);
break;
case 'owner_inventory':
if (context.role !== 'owner') {
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Esta opción solo está disponible para el dueño del negocio.',
context.tenantId,
);
return;
}
await this.sendOwnerInventoryStatus(phoneNumber, context);
break;
case 'owner_fiados':
if (context.role !== 'owner') {
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Esta opción solo está disponible para el dueño del negocio.',
context.tenantId,
);
return;
}
await this.sendOwnerFiadosSummary(phoneNumber, context);
break;
case 'owner_send_reminders':
if (context.role !== 'owner') return;
await this.sendPaymentReminders(phoneNumber, context);
break;
default:
await this.whatsAppService.sendTextMessage(
phoneNumber,
@ -295,28 +359,57 @@ export class WebhookService {
context: ConversationContext,
): Promise<void> {
const businessName = context.businessName || 'MiChangarrito';
const message = `Hola ${customerName}! Bienvenido a ${businessName}.
if (context.role === 'owner') {
// Welcome message for business owner
const message = `Hola ${customerName}! 👋
Soy tu asistente de ${businessName}. Como dueño, puedo ayudarte con:
- 📊 Consultar ventas del día
- 📦 Revisar inventario y stock bajo
- 💰 Ver fiados pendientes
- 📈 Generar reportes
- Modificar precios
¿Qué necesitas hoy?`;
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'owner_sales', title: '📊 Ventas hoy' },
{ id: 'owner_inventory', title: '📦 Inventario' },
{ id: 'owner_fiados', title: '💰 Fiados' },
],
undefined,
undefined,
context.tenantId,
);
} else {
// Welcome message for customers
const message = `Hola ${customerName}! 👋 Bienvenido a ${businessName}.
Soy tu asistente virtual. Puedo ayudarte con:
- Ver productos disponibles
- Hacer pedidos
- Consultar tu cuenta de fiado
- Revisar el estado de tus pedidos
- 🛒 Ver productos disponibles
- 📝 Hacer pedidos
- 💳 Consultar tu cuenta de fiado
- 📦 Revisar el estado de tus pedidos
Como puedo ayudarte hoy?`;
¿mo puedo ayudarte hoy?`;
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'menu_products', title: 'Ver productos' },
{ id: 'menu_orders', title: 'Mis pedidos' },
{ id: 'menu_fiado', title: 'Mi fiado' },
],
undefined,
undefined,
context.tenantId,
);
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'menu_products', title: '🛒 Ver productos' },
{ id: 'menu_orders', title: '📦 Mis pedidos' },
{ id: 'menu_fiado', title: '💳 Mi fiado' },
],
undefined,
undefined,
context.tenantId,
);
}
}
private async sendMainMenu(phoneNumber: string, context: ConversationContext): Promise<void> {
@ -541,6 +634,177 @@ Tambien puedes escribirme de forma natural y tratare de entenderte!`;
}
}
// ==================== OWNER ACTIONS (MCH-012) ====================
private async sendOwnerSalesSummary(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch real sales data from backend
const today = new Date().toLocaleDateString('es-MX', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Mock data - in production this would call the backend
const salesData = {
total: 2450.50,
count: 15,
profit: 650.50,
avgTicket: 163.37,
topProducts: [
{ name: 'Coca-Cola 600ml', qty: 24, revenue: 432 },
{ name: 'Sabritas Original', qty: 18, revenue: 270 },
{ name: 'Pan Bimbo', qty: 10, revenue: 450 },
],
vsYesterday: '+12%',
};
const topList = salesData.topProducts
.map((p, i) => `${i + 1}. ${p.name}: ${p.qty} uds ($${p.revenue})`)
.join('\n');
const message = `📊 *Resumen de Ventas*
_${today}_
💰 *Total:* $${salesData.total.toFixed(2)}
📦 *Transacciones:* ${salesData.count}
📈 *Ganancia:* $${salesData.profit.toFixed(2)}
🎫 *Ticket promedio:* $${salesData.avgTicket.toFixed(2)}
📊 *vs ayer:* ${salesData.vsYesterday}
*Top productos:*
${topList}
¿Necesitas más detalles?`;
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'owner_inventory', title: '📦 Ver inventario' },
{ id: 'owner_fiados', title: '💰 Ver fiados' },
],
undefined,
undefined,
context.tenantId,
);
}
private async sendOwnerInventoryStatus(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch real inventory data from backend
const lowStock = [
{ name: 'Coca-Cola 600ml', stock: 5, daysLeft: 2 },
{ name: 'Leche Lala 1L', stock: 3, daysLeft: 1 },
{ name: 'Pan Bimbo Grande', stock: 4, daysLeft: 2 },
];
const expiringSoon = [
{ name: 'Yogurt Danone', expiresIn: '2 días', qty: 6 },
];
const lowStockList = lowStock
.map((p) => `⚠️ ${p.name}: ${p.stock} uds (~${p.daysLeft} días)`)
.join('\n');
const expiringList = expiringSoon.length > 0
? expiringSoon.map((p) => `${p.name}: ${p.qty} uds (vence en ${p.expiresIn})`).join('\n')
: 'Ninguno próximo a vencer';
const message = `📦 *Estado del Inventario*
*Productos con stock bajo:*
${lowStockList}
*Próximos a vencer:*
${expiringList}
💡 _Tip: Considera hacer pedido al proveedor_
¿Quieres que te recuerde más tarde?`;
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'owner_sales', title: '📊 Ver ventas' },
{ id: 'menu_products', title: '🛒 Ver catálogo' },
],
undefined,
undefined,
context.tenantId,
);
}
private async sendOwnerFiadosSummary(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch real fiados data from backend
const fiadosData = {
totalPending: 2150.00,
clientCount: 8,
overdueCount: 3,
topDebtors: [
{ name: 'Juan Pérez', amount: 850, days: 15 },
{ name: 'María López', amount: 420, days: 7 },
{ name: 'Pedro García', amount: 380, days: 3 },
],
};
const debtorsList = fiadosData.topDebtors
.map((d, i) => `${i + 1}. ${d.name}: $${d.amount} (${d.days} días)`)
.join('\n');
const message = `💰 *Resumen de Fiados*
📊 *Total pendiente:* $${fiadosData.totalPending.toFixed(2)}
👥 *Clientes con deuda:* ${fiadosData.clientCount}
*Con atraso (+7 días):* ${fiadosData.overdueCount}
*Mayores adeudos:*
${debtorsList}
¿Quieres enviar recordatorios de cobro?`;
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'owner_send_reminders', title: '📤 Enviar recordatorios' },
{ id: 'owner_sales', title: '📊 Ver ventas' },
],
undefined,
undefined,
context.tenantId,
);
}
private async sendPaymentReminders(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Actually send reminders via backend
const overdueClients = 3;
await this.whatsAppService.sendTextMessage(
phoneNumber,
`✅ *Recordatorios enviados*
Se enviaron recordatorios de pago a ${overdueClients} clientes con atrasos mayores a 7 días.
Los clientes recibirán un mensaje amable recordándoles su saldo pendiente.
_Nota: Solo se envía un recordatorio por semana a cada cliente._`,
context.tenantId,
);
}
// ==================== STATUS UPDATES ====================
processStatusUpdate(status: WebhookStatus): void {