refactor stateHandlers
This commit is contained in:
File diff suppressed because it is too large
Load Diff
554
src/modules/3-turn-engine/stateHandlers/cart.js
Normal file
554
src/modules/3-turn-engine/stateHandlers/cart.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* Handler para el estado CART
|
||||
* Maneja: view_cart, remove_from_cart, confirm_order, recommend, price_query, add_to_cart
|
||||
*/
|
||||
|
||||
import { retrieveCandidates } from "../catalogRetrieval.js";
|
||||
import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "../fsm.js";
|
||||
import {
|
||||
createEmptyOrder,
|
||||
PendingStatus,
|
||||
moveReadyToCart,
|
||||
getNextPendingItem,
|
||||
updatePendingItem,
|
||||
addPendingItem,
|
||||
formatCartForDisplay,
|
||||
formatOptionsForDisplay,
|
||||
removeCartItem,
|
||||
} from "../orderModel.js";
|
||||
import { handleRecommend } from "../recommendations.js";
|
||||
import { getProductQtyRules } from "../../0-ui/db/repo.js";
|
||||
import { inferDefaultUnit, unitAskFor } from "./utils.js";
|
||||
import {
|
||||
extractProductQueries,
|
||||
createPendingItemFromSearch,
|
||||
processPendingClarification
|
||||
} from "./cartHelpers.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado CART (carrito activo)
|
||||
*/
|
||||
export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
|
||||
// Intents que tienen prioridad sobre pending items
|
||||
const priorityIntents = ["view_cart", "confirm_order", "greeting"];
|
||||
const isPriorityIntent = priorityIntents.includes(intent);
|
||||
|
||||
// Detectar si el usuario quiere cancelar/saltar el pending item actual
|
||||
const pendingItem = getNextPendingItem(currentOrder);
|
||||
const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i;
|
||||
const wantsToSkipPending = pendingItem && cancelPhrases.test(text || "");
|
||||
|
||||
// Si quiere saltar el pending - PERO solo si NO es un intent prioritario
|
||||
if (wantsToSkipPending && pendingItem && !isPriorityIntent) {
|
||||
return handleSkipPending({ currentOrder, pendingItem, audit });
|
||||
}
|
||||
|
||||
// 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación
|
||||
if (pendingItem && !isPriorityIntent) {
|
||||
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit });
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 2) view_cart: mostrar carrito actual
|
||||
if (intent === "view_cart") {
|
||||
return handleViewCart({ currentOrder });
|
||||
}
|
||||
|
||||
// 2.5) remove_from_cart: quitar productos del carrito
|
||||
if (intent === "remove_from_cart") {
|
||||
return handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit });
|
||||
}
|
||||
|
||||
// 3) confirm_order: ir a SHIPPING si hay items
|
||||
if (intent === "confirm_order") {
|
||||
return handleConfirmOrder({ currentOrder, audit });
|
||||
}
|
||||
|
||||
// 4) recommend
|
||||
if (intent === "recommend") {
|
||||
const result = await handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit });
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 4.5) price_query - consulta de precios
|
||||
if (intent === "price_query") {
|
||||
return handlePriceQuery({ tenantId, nlu, currentOrder, audit });
|
||||
}
|
||||
|
||||
// 5) add_to_cart / browse: buscar productos
|
||||
if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) {
|
||||
return handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit });
|
||||
}
|
||||
|
||||
// Default
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué más querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja el skip de un pending item
|
||||
*/
|
||||
function handleSkipPending({ currentOrder, pendingItem, audit }) {
|
||||
const updatedOrder = {
|
||||
...currentOrder,
|
||||
pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
|
||||
};
|
||||
audit.skipped_pending = pendingItem.query;
|
||||
|
||||
const nextPending = getNextPendingItem(updatedOrder);
|
||||
if (nextPending) {
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Ok, salteo "${pendingItem.query}". ${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const cartDisplay = formatCartForDisplay(updatedOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja view_cart
|
||||
*/
|
||||
function handleViewCart({ currentOrder }) {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0;
|
||||
let reply = cartDisplay;
|
||||
if (pendingCount > 0) {
|
||||
reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`;
|
||||
}
|
||||
reply += "\n\n¿Algo más?";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "view_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit: {} },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja remove_from_cart
|
||||
*/
|
||||
async function handleRemoveFromCart({ tenantId, text, nlu, currentOrder, audit }) {
|
||||
const items = nlu?.entities?.items || [];
|
||||
const removedItems = [];
|
||||
const addedItems = [];
|
||||
const notFoundItems = [];
|
||||
let updatedOrder = currentOrder;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.product_query) continue;
|
||||
|
||||
const { order: orderAfterRemove, removed } = removeCartItem(updatedOrder, item.product_query);
|
||||
|
||||
if (removed) {
|
||||
removedItems.push(removed.name || item.product_query);
|
||||
updatedOrder = orderAfterRemove;
|
||||
} else {
|
||||
notFoundItems.push(item.product_query);
|
||||
}
|
||||
|
||||
if (item.quantity && item.quantity > 0) {
|
||||
addedItems.push({ query: item.product_query, qty: item.quantity, unit: item.unit });
|
||||
}
|
||||
}
|
||||
|
||||
// Si hay items para agregar
|
||||
if (addedItems.length > 0) {
|
||||
for (const addItem of addedItems) {
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: addItem.query, limit: 20 });
|
||||
const candidates = searchResult?.candidates || [];
|
||||
|
||||
const pendingItem = createPendingItemFromSearch({
|
||||
query: addItem.query,
|
||||
quantity: addItem.qty,
|
||||
unit: addItem.unit,
|
||||
candidates,
|
||||
});
|
||||
|
||||
updatedOrder = addPendingItem(updatedOrder, pendingItem);
|
||||
}
|
||||
updatedOrder = moveReadyToCart(updatedOrder);
|
||||
}
|
||||
|
||||
// Generar respuesta
|
||||
let reply = "";
|
||||
if (removedItems.length > 0) {
|
||||
reply += `Listo, saqué: ${removedItems.join(", ")}. `;
|
||||
}
|
||||
if (notFoundItems.length > 0 && removedItems.length === 0) {
|
||||
reply += `No encontré "${notFoundItems.join(", ")}" en tu carrito. `;
|
||||
}
|
||||
|
||||
const nextPending = getNextPendingItem(updatedOrder);
|
||||
if (nextPending && nextPending.status === PendingStatus.NEEDS_TYPE) {
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
reply += question;
|
||||
return {
|
||||
plan: {
|
||||
reply: reply.trim(),
|
||||
next_state: ConversationState.CART,
|
||||
intent: "remove_from_cart",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: removedItems.length > 0 ? "remove_from_cart" : "none",
|
||||
},
|
||||
decision: {
|
||||
actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [],
|
||||
order: updatedOrder,
|
||||
audit
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cartDisplay = formatCartForDisplay(updatedOrder);
|
||||
reply += `\n\n${cartDisplay}\n\n¿Algo más?`;
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: reply.trim(),
|
||||
next_state: ConversationState.CART,
|
||||
intent: "remove_from_cart",
|
||||
missing_fields: [],
|
||||
order_action: removedItems.length > 0 ? "remove_from_cart" : "none",
|
||||
},
|
||||
decision: {
|
||||
actions: removedItems.length > 0 ? [{ type: "remove_from_cart", payload: { removed: removedItems } }] : [],
|
||||
order: updatedOrder,
|
||||
audit
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja confirm_order
|
||||
*/
|
||||
function handleConfirmOrder({ currentOrder, audit }) {
|
||||
let order = moveReadyToCart(currentOrder);
|
||||
|
||||
if (!hasCartItems(order)) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "Tu carrito está vacío. ¿Qué querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["cart_items"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
if (hasPendingItems(order)) {
|
||||
const nextPending = getNextPendingItem(order);
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["pending_items"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const { next_state } = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
|
||||
next_state,
|
||||
intent: "confirm_order",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja recommend
|
||||
*/
|
||||
async function handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit }) {
|
||||
try {
|
||||
const recoResult = await handleRecommend({
|
||||
tenantId,
|
||||
text,
|
||||
nlu,
|
||||
order: currentOrder,
|
||||
prevContext: { order: currentOrder },
|
||||
audit
|
||||
});
|
||||
if (recoResult?.plan?.reply) {
|
||||
const newOrder = recoResult.decision?.order || currentOrder;
|
||||
const contextPatch = recoResult.decision?.context_patch || {};
|
||||
return {
|
||||
plan: {
|
||||
...recoResult.plan,
|
||||
next_state: ConversationState.CART,
|
||||
},
|
||||
decision: {
|
||||
actions: recoResult.decision?.actions || [],
|
||||
order: newOrder,
|
||||
audit,
|
||||
context_patch: contextPatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
audit.recommend_error = String(e?.message || e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja price_query
|
||||
*/
|
||||
async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) {
|
||||
const productQueries = extractProductQueries(nlu);
|
||||
|
||||
if (productQueries.length === 0) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿De qué producto querés saber el precio?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const priceResults = [];
|
||||
for (const pq of productQueries.slice(0, 5)) {
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 });
|
||||
const candidates = searchResult?.candidates || [];
|
||||
audit.price_search = audit.price_search || [];
|
||||
audit.price_search.push({ query: pq.query, count: candidates.length });
|
||||
|
||||
for (const c of candidates.slice(0, 2)) {
|
||||
const unit = inferDefaultUnit({ name: c.name, categories: c.categories });
|
||||
const priceStr = c.price != null ? `$${c.price}` : "consultar";
|
||||
const unitStr = unit === "unit" ? "/unidad" : "/kg";
|
||||
priceResults.push(`• ${c.name}: ${priceStr}${unitStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (priceResults.length === 0) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "No encontré ese producto. ¿Podés ser más específico?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?";
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "price_query",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja add_to_cart / browse
|
||||
*/
|
||||
async function handleAddToCart({ tenantId, text, nlu, currentOrder, intent, audit }) {
|
||||
const productQueries = extractProductQueries(nlu);
|
||||
|
||||
if (productQueries.length === 0) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué producto querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["product_query"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
let order = currentOrder;
|
||||
|
||||
// Buscar candidatos para cada query
|
||||
for (const pq of productQueries) {
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 });
|
||||
const candidates = searchResult?.candidates || [];
|
||||
audit.catalog_search = audit.catalog_search || [];
|
||||
audit.catalog_search.push({ query: pq.query, count: candidates.length });
|
||||
|
||||
const pendingItem = createPendingItemFromSearch({
|
||||
query: pq.query,
|
||||
quantity: pq.quantity,
|
||||
unit: pq.unit,
|
||||
candidates,
|
||||
});
|
||||
|
||||
order = addPendingItem(order, pendingItem);
|
||||
}
|
||||
|
||||
order = moveReadyToCart(order);
|
||||
|
||||
// Si hay pending items, pedir clarificación del primero
|
||||
const nextPending = getNextPendingItem(order);
|
||||
if (nextPending) {
|
||||
if (nextPending.status === PendingStatus.NEEDS_TYPE) {
|
||||
const { question } = formatOptionsForDisplay(nextPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
|
||||
return handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit });
|
||||
}
|
||||
}
|
||||
|
||||
// Todo resuelto, confirmar agregado
|
||||
const lastAdded = order.cart[order.cart.length - 1];
|
||||
if (lastAdded) {
|
||||
const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`;
|
||||
const cartSummary = formatCartForDisplay(order);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Qué más querés agregar?",
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cuando se necesita cantidad
|
||||
*/
|
||||
async function handleQuantityNeeded({ tenantId, text, order, nextPending, intent, audit }) {
|
||||
// Detectar "para X personas" en el texto original
|
||||
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
||||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
|
||||
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
|
||||
|
||||
if (personasMatch && nextPending.selected_woo_id) {
|
||||
const peopleCount = parseInt(personasMatch[1], 10);
|
||||
|
||||
if (peopleCount > 0 && peopleCount <= 100) {
|
||||
let qtyRules = [];
|
||||
try {
|
||||
qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id });
|
||||
} catch (e) {
|
||||
audit.qty_rules_error = e?.message;
|
||||
}
|
||||
|
||||
let calculatedQty;
|
||||
let calculatedUnit = nextPending.selected_unit || "kg";
|
||||
const rule = qtyRules[0];
|
||||
|
||||
if (rule && rule.qty_per_person > 0) {
|
||||
calculatedQty = rule.qty_per_person * peopleCount;
|
||||
calculatedUnit = rule.unit || calculatedUnit;
|
||||
audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount };
|
||||
} else {
|
||||
calculatedQty = 0.3 * peopleCount;
|
||||
audit.qty_fallback = { default_per_person: 0.3, people: peopleCount };
|
||||
}
|
||||
|
||||
const updatedOrder = updatePendingItem(order, nextPending.id, {
|
||||
qty: calculatedQty,
|
||||
unit: calculatedUnit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Si no hay "para X personas", preguntar cantidad normalmente
|
||||
const unitQuestion = unitAskFor(nextPending.selected_unit || "kg");
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent,
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
538
src/modules/3-turn-engine/stateHandlers/cartHelpers.js
Normal file
538
src/modules/3-turn-engine/stateHandlers/cartHelpers.js
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Helpers específicos para el manejo del carrito
|
||||
* - Extracción de queries de productos
|
||||
* - Creación de pending items
|
||||
* - Procesamiento de clarificaciones
|
||||
*/
|
||||
|
||||
import { retrieveCandidates } from "../catalogRetrieval.js";
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import {
|
||||
createPendingItem,
|
||||
PendingStatus,
|
||||
moveReadyToCart,
|
||||
updatePendingItem,
|
||||
formatCartForDisplay,
|
||||
formatOptionsForDisplay,
|
||||
} from "../orderModel.js";
|
||||
import { getProductQtyRules } from "../../0-ui/db/repo.js";
|
||||
import { createHumanTakeoverResponse } from "../nlu/humanFallback.js";
|
||||
import {
|
||||
inferDefaultUnit,
|
||||
parseIndexSelection,
|
||||
isShowMoreRequest,
|
||||
isShowOptionsRequest,
|
||||
findMatchingCandidate,
|
||||
isEscapeRequest,
|
||||
normalizeUnit,
|
||||
unitAskFor,
|
||||
} from "./utils.js";
|
||||
|
||||
/**
|
||||
* Extrae queries de productos del resultado NLU
|
||||
*/
|
||||
export function extractProductQueries(nlu) {
|
||||
const queries = [];
|
||||
|
||||
// Multi-items
|
||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) {
|
||||
for (const item of nlu.entities.items) {
|
||||
if (item.product_query) {
|
||||
queries.push({
|
||||
query: item.product_query,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
});
|
||||
}
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
// Single item
|
||||
if (nlu?.entities?.product_query) {
|
||||
queries.push({
|
||||
query: nlu.entities.product_query,
|
||||
quantity: nlu.entities.quantity,
|
||||
unit: nlu.entities.unit,
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un pending item a partir de los resultados de búsqueda
|
||||
*/
|
||||
export function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
|
||||
const cands = (candidates || []).filter(c => c && c.woo_product_id);
|
||||
|
||||
if (cands.length === 0) {
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: [],
|
||||
status: PendingStatus.NEEDS_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for strong match
|
||||
const best = cands[0];
|
||||
const second = cands[1];
|
||||
const isStrong = cands.length === 1 ||
|
||||
(best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
|
||||
|
||||
if (isStrong) {
|
||||
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||
const sellsByWeight = displayUnit !== "unit";
|
||||
const hasExplicitUnit = unit != null && unit !== "";
|
||||
const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit;
|
||||
const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric);
|
||||
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: [],
|
||||
selected_woo_id: best.woo_product_id,
|
||||
selected_name: best.name,
|
||||
selected_price: best.price,
|
||||
selected_unit: displayUnit,
|
||||
qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1),
|
||||
unit: normalizeUnit(unit) || displayUnit,
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple candidates, needs selection
|
||||
const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
|
||||
return createPendingItem({
|
||||
query,
|
||||
candidates: cands.slice(0, 20).map(c => ({
|
||||
woo_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
sell_unit: c.sell_unit || null,
|
||||
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
|
||||
})),
|
||||
status: PendingStatus.NEEDS_TYPE,
|
||||
requested_qty: hasQty ? Number(quantity) : null,
|
||||
requested_unit: normalizeUnit(unit) || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la clarificación de un pending item
|
||||
* Retorna un resultado si se pudo procesar, null si debe escapar al handler principal
|
||||
*/
|
||||
export async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
// Detectar intents que deberían escapar de la clarificación
|
||||
const escapeIntents = ["view_cart", "confirm_order", "greeting", "cancel_order"];
|
||||
if (escapeIntents.includes(nlu?.intent)) {
|
||||
audit.escape_from_pending = { reason: "intent", intent: nlu?.intent };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detectar frases de escape explícitas
|
||||
if (isEscapeRequest(text)) {
|
||||
audit.escape_from_pending = { reason: "text_pattern", text };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si necesita seleccionar tipo
|
||||
if (pendingItem.status === PendingStatus.NEEDS_TYPE) {
|
||||
return processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit });
|
||||
}
|
||||
|
||||
// Si necesita cantidad
|
||||
if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) {
|
||||
return processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la selección de tipo de producto
|
||||
*/
|
||||
async function processTypeSelection({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
const idx = parseIndexSelection(text);
|
||||
|
||||
// Show more o mostrar opciones
|
||||
if (isShowMoreRequest(text) || isShowOptionsRequest(text)) {
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Intentar matchear el texto contra los candidatos existentes ANTES de re-buscar
|
||||
const textMatch = !idx && pendingItem.candidates?.length > 0
|
||||
? findMatchingCandidate(pendingItem.candidates, text)
|
||||
: null;
|
||||
|
||||
const effectiveIdx = idx || (textMatch ? textMatch.index + 1 : null);
|
||||
|
||||
// Selection by index (o por match de texto)
|
||||
if (effectiveIdx && pendingItem.candidates && effectiveIdx <= pendingItem.candidates.length) {
|
||||
return processIndexSelection({ order, pendingItem, effectiveIdx, audit });
|
||||
}
|
||||
|
||||
// Si el usuario da texto libre (no número ni match con candidatos), intentar re-buscar
|
||||
const isNumberSelection = idx !== null;
|
||||
const hadTextMatch = effectiveIdx !== null && !idx;
|
||||
const isTextClarification = !isNumberSelection && !hadTextMatch && !isShowMoreRequest(text) && !isShowOptionsRequest(text) && text && text.length > 2;
|
||||
|
||||
if (isTextClarification) {
|
||||
return processTextClarification({ tenantId, text, order, pendingItem, audit });
|
||||
}
|
||||
|
||||
// No entendió (no es número, no es texto largo), volver a preguntar
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
return {
|
||||
plan: {
|
||||
reply: "No entendí. " + question,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa selección por índice
|
||||
*/
|
||||
function processIndexSelection({ order, pendingItem, effectiveIdx, audit }) {
|
||||
const selected = pendingItem.candidates[effectiveIdx - 1];
|
||||
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
|
||||
|
||||
const requestedQty = pendingItem.requested_qty;
|
||||
const requestedUnit = pendingItem.requested_unit || displayUnit;
|
||||
const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0;
|
||||
|
||||
const sellsByWeight = displayUnit !== "unit";
|
||||
const needsQuantity = sellsByWeight && !hasRequestedQty;
|
||||
|
||||
const finalQty = hasRequestedQty ? requestedQty : 1;
|
||||
const finalUnit = requestedUnit || displayUnit;
|
||||
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
selected_woo_id: selected.woo_id,
|
||||
selected_name: selected.name,
|
||||
selected_price: selected.price,
|
||||
selected_unit: displayUnit,
|
||||
candidates: [],
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
qty: needsQuantity ? null : finalQty,
|
||||
unit: finalUnit,
|
||||
});
|
||||
|
||||
if (needsQuantity) {
|
||||
const unitQuestion = unitAskFor(displayUnit);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${selected.name}, ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyDisplay = displayUnit === "unit"
|
||||
? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
|
||||
: `${finalQty}${displayUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa clarificación por texto libre (re-búsqueda)
|
||||
*/
|
||||
async function processTextClarification({ tenantId, text, order, pendingItem, audit }) {
|
||||
const newQuery = text.trim();
|
||||
const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 });
|
||||
const newCandidates = searchResult?.candidates || [];
|
||||
audit.retry_search = { query: newQuery, count: newCandidates.length, had_previous: pendingItem.candidates?.length || 0 };
|
||||
|
||||
if (newCandidates.length > 0) {
|
||||
const updatedPending = {
|
||||
...pendingItem,
|
||||
query: newQuery,
|
||||
candidates: newCandidates.slice(0, 20).map(c => ({
|
||||
woo_id: c.woo_product_id,
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
sell_unit: c.sell_unit || null,
|
||||
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
|
||||
})),
|
||||
};
|
||||
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p),
|
||||
};
|
||||
|
||||
// Si hay match fuerte, seleccionar automáticamente
|
||||
if (newCandidates.length === 1 || (newCandidates[0]?._score >= 0.9)) {
|
||||
return processStrongMatch({ updatedOrder, pendingItem, best: newCandidates[0], audit });
|
||||
}
|
||||
|
||||
// Múltiples candidatos, mostrar opciones
|
||||
const { question } = formatOptionsForDisplay(updatedPending);
|
||||
return {
|
||||
plan: {
|
||||
reply: question,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: updatedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// No encontró nada con la nueva búsqueda
|
||||
if (!pendingItem.candidates || pendingItem.candidates.length === 0) {
|
||||
const orderWithoutPending = {
|
||||
...order,
|
||||
pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
|
||||
};
|
||||
|
||||
audit.escalated_to_human = true;
|
||||
audit.original_query = pendingItem.query;
|
||||
audit.retry_query = newQuery;
|
||||
|
||||
return createHumanTakeoverResponse({
|
||||
pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
|
||||
order: orderWithoutPending,
|
||||
context: {
|
||||
original_query: pendingItem.query,
|
||||
user_clarification: newQuery,
|
||||
search_attempts: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Había candidatos pero la búsqueda nueva no encontró, mantener los anteriores
|
||||
const { question } = formatOptionsForDisplay(pendingItem);
|
||||
return {
|
||||
plan: {
|
||||
reply: `No encontré "${newQuery}". ${question}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["product_selection"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un match fuerte (selección automática)
|
||||
*/
|
||||
function processStrongMatch({ updatedOrder, pendingItem, best, audit }) {
|
||||
const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories });
|
||||
const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0;
|
||||
const needsQuantity = displayUnit !== "unit" && !hasQty;
|
||||
|
||||
const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, {
|
||||
selected_woo_id: best.woo_product_id,
|
||||
selected_name: best.name,
|
||||
selected_price: best.price,
|
||||
selected_unit: displayUnit,
|
||||
candidates: [],
|
||||
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||
qty: needsQuantity ? null : (hasQty ? pendingItem.requested_qty : 1),
|
||||
unit: pendingItem.requested_unit || displayUnit,
|
||||
});
|
||||
|
||||
if (needsQuantity) {
|
||||
const unitQuestion = unitAskFor(displayUnit);
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${best.name}, ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: autoSelectedOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
const finalOrder = moveReadyToCart(autoSelectedOrder);
|
||||
const qty = hasQty ? pendingItem.requested_qty : 1;
|
||||
const qtyDisplay = displayUnit === "unit"
|
||||
? `${qty} ${qty === 1 ? 'unidad de' : 'unidades de'}`
|
||||
: `${qty}${displayUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyDisplay} ${best.name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa input de cantidad
|
||||
*/
|
||||
async function processQuantityInput({ tenantId, text, nlu, order, pendingItem, audit }) {
|
||||
const qty = nlu?.entities?.quantity;
|
||||
const unit = nlu?.entities?.unit;
|
||||
|
||||
// Try to parse quantity from text
|
||||
let parsedQty = qty;
|
||||
if (parsedQty == null) {
|
||||
const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || "");
|
||||
if (m) {
|
||||
parsedQty = parseFloat(m[1].replace(",", "."));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) {
|
||||
const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg";
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
qty: parsedQty,
|
||||
unit: finalUnit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Detectar "para X personas" y calcular cantidad automáticamente
|
||||
const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") ||
|
||||
/\bpara\s+(\d+)\b/i.exec(text || "") ||
|
||||
/\bcomo\s+para\s+(\d+)\b/i.exec(text || "");
|
||||
|
||||
if (personasMatch && pendingItem.selected_woo_id) {
|
||||
return processQuantityForPeople({ tenantId, text, order, pendingItem, audit, personasMatch });
|
||||
}
|
||||
|
||||
// No entendió cantidad
|
||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||
return {
|
||||
plan: {
|
||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa cantidad para X personas
|
||||
*/
|
||||
async function processQuantityForPeople({ tenantId, order, pendingItem, audit, personasMatch }) {
|
||||
const peopleCount = parseInt(personasMatch[1], 10);
|
||||
|
||||
if (peopleCount <= 0 || peopleCount > 100) {
|
||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||
return {
|
||||
plan: {
|
||||
reply: `No entendí la cantidad. ${unitQuestion}`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "other",
|
||||
missing_fields: ["quantity"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar reglas de cantidad por persona para este producto
|
||||
let qtyRules = [];
|
||||
try {
|
||||
qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id });
|
||||
} catch (e) {
|
||||
audit.qty_rules_error = e?.message;
|
||||
}
|
||||
|
||||
const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") ||
|
||||
qtyRules.find(r => r.event_type === null && r.person_type === "adult") ||
|
||||
qtyRules.find(r => r.person_type === "adult") ||
|
||||
qtyRules[0];
|
||||
|
||||
let calculatedQty;
|
||||
let calculatedUnit = pendingItem.selected_unit || "kg";
|
||||
|
||||
if (rule && rule.qty_per_person > 0) {
|
||||
calculatedQty = rule.qty_per_person * peopleCount;
|
||||
calculatedUnit = rule.unit || calculatedUnit;
|
||||
audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit };
|
||||
} else {
|
||||
const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3;
|
||||
calculatedQty = fallbackPerPerson * peopleCount;
|
||||
audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit };
|
||||
}
|
||||
|
||||
// Redondear
|
||||
if (calculatedUnit === "unit") {
|
||||
calculatedQty = Math.ceil(calculatedQty);
|
||||
} else {
|
||||
calculatedQty = Math.round(calculatedQty * 10) / 10;
|
||||
}
|
||||
|
||||
const updatedOrder = updatePendingItem(order, pendingItem.id, {
|
||||
qty: calculatedQty,
|
||||
unit: calculatedUnit,
|
||||
status: PendingStatus.READY,
|
||||
});
|
||||
|
||||
const finalOrder = moveReadyToCart(updatedOrder);
|
||||
const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`;
|
||||
const cartSummary = formatCartForDisplay(finalOrder);
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}.\n\n${cartSummary}\n\n¿Algo más?`,
|
||||
next_state: ConversationState.CART,
|
||||
intent: "add_to_cart",
|
||||
missing_fields: [],
|
||||
order_action: "add_to_cart",
|
||||
},
|
||||
decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit },
|
||||
};
|
||||
}
|
||||
44
src/modules/3-turn-engine/stateHandlers/idle.js
Normal file
44
src/modules/3-turn-engine/stateHandlers/idle.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Handler para el estado IDLE
|
||||
*/
|
||||
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { handleCartState } from "./cart.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado IDLE (inicio de conversación)
|
||||
*/
|
||||
export async function handleIdleState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
|
||||
// Greeting
|
||||
if (intent === "greeting") {
|
||||
return {
|
||||
plan: {
|
||||
reply: "¡Hola! ¿En qué te puedo ayudar hoy?",
|
||||
next_state: ConversationState.IDLE,
|
||||
intent: "greeting",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Cualquier intent relacionado con productos → ir a CART
|
||||
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
||||
return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true });
|
||||
}
|
||||
|
||||
// Other
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.",
|
||||
next_state: ConversationState.IDLE,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order, audit },
|
||||
};
|
||||
}
|
||||
32
src/modules/3-turn-engine/stateHandlers/index.js
Normal file
32
src/modules/3-turn-engine/stateHandlers/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* State Handlers - Punto de entrada
|
||||
*
|
||||
* Re-exporta todos los handlers y utilidades para mantener
|
||||
* compatibilidad con imports existentes.
|
||||
*/
|
||||
|
||||
// Handlers por estado
|
||||
export { handleIdleState } from "./idle.js";
|
||||
export { handleCartState } from "./cart.js";
|
||||
export { handleShippingState } from "./shipping.js";
|
||||
export { handlePaymentState } from "./payment.js";
|
||||
export { handleWaitingState } from "./waiting.js";
|
||||
|
||||
// Utilidades (para uso interno principalmente)
|
||||
export {
|
||||
inferDefaultUnit,
|
||||
parseIndexSelection,
|
||||
isShowMoreRequest,
|
||||
isShowOptionsRequest,
|
||||
findMatchingCandidate,
|
||||
isEscapeRequest,
|
||||
normalizeUnit,
|
||||
unitAskFor,
|
||||
} from "./utils.js";
|
||||
|
||||
// Helpers de carrito (para uso interno principalmente)
|
||||
export {
|
||||
extractProductQueries,
|
||||
createPendingItemFromSearch,
|
||||
processPendingClarification,
|
||||
} from "./cartHelpers.js";
|
||||
87
src/modules/3-turn-engine/stateHandlers/payment.js
Normal file
87
src/modules/3-turn-engine/stateHandlers/payment.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Handler para el estado PAYMENT
|
||||
*/
|
||||
|
||||
import { ConversationState, safeNextState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
import { parseIndexSelection } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado PAYMENT (selección de método de pago)
|
||||
*/
|
||||
export async function handlePaymentState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
const actions = [];
|
||||
|
||||
// Detectar selección de pago
|
||||
let paymentMethod = nlu?.entities?.payment_method;
|
||||
|
||||
if (!paymentMethod) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const idx = parseIndexSelection(text);
|
||||
if (idx === 1 || /efectivo|cash|plata/i.test(t)) {
|
||||
paymentMethod = "cash";
|
||||
} else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) {
|
||||
paymentMethod = "link";
|
||||
}
|
||||
}
|
||||
|
||||
if (paymentMethod) {
|
||||
currentOrder = { ...currentOrder, payment_type: paymentMethod };
|
||||
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
|
||||
|
||||
if (paymentMethod === "link") {
|
||||
actions.push({ type: "send_payment_link", payload: {} });
|
||||
}
|
||||
|
||||
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
|
||||
|
||||
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
|
||||
const deliveryInfo = currentOrder.is_delivery
|
||||
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
|
||||
: "Retiro en sucursal.";
|
||||
|
||||
const paymentInfo = paymentMethod === "link"
|
||||
? "Te paso el link de pago en un momento."
|
||||
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`,
|
||||
next_state,
|
||||
intent: "select_payment",
|
||||
missing_fields: [],
|
||||
order_action: "create_order",
|
||||
},
|
||||
decision: { actions, order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// view_cart
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: cartDisplay + "\n\n¿Cómo preferís pagar?",
|
||||
next_state: ConversationState.PAYMENT,
|
||||
intent: "view_cart",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
|
||||
next_state: ConversationState.PAYMENT,
|
||||
intent: "other",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
120
src/modules/3-turn-engine/stateHandlers/shipping.js
Normal file
120
src/modules/3-turn-engine/stateHandlers/shipping.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Handler para el estado SHIPPING
|
||||
*/
|
||||
|
||||
import { ConversationState, safeNextState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
import { parseIndexSelection } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado SHIPPING (selección de envío)
|
||||
*/
|
||||
export async function handleShippingState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
let currentOrder = order || createEmptyOrder();
|
||||
|
||||
// Detectar selección de shipping (delivery/pickup)
|
||||
let shippingMethod = nlu?.entities?.shipping_method;
|
||||
|
||||
// Detectar por número o texto
|
||||
if (!shippingMethod) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const idx = parseIndexSelection(text);
|
||||
if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) {
|
||||
shippingMethod = "delivery";
|
||||
} else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) {
|
||||
shippingMethod = "pickup";
|
||||
}
|
||||
}
|
||||
|
||||
if (shippingMethod) {
|
||||
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
||||
|
||||
if (shippingMethod === "pickup") {
|
||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.",
|
||||
next_state,
|
||||
intent: "select_shipping",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Delivery: pedir dirección si no la tiene
|
||||
if (!currentOrder.shipping_address) {
|
||||
return {
|
||||
plan: {
|
||||
reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "select_shipping",
|
||||
missing_fields: ["address"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Si ya eligió delivery y ahora da dirección
|
||||
if (currentOrder.is_delivery === true && !currentOrder.shipping_address) {
|
||||
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
|
||||
|
||||
if (address) {
|
||||
currentOrder = { ...currentOrder, shipping_address: address };
|
||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`,
|
||||
next_state,
|
||||
intent: "provide_address",
|
||||
missing_fields: ["payment_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply: "Necesito la dirección de entrega. ¿Me la pasás?",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "other",
|
||||
missing_fields: ["address"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// view_cart
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
return {
|
||||
plan: {
|
||||
reply: cartDisplay + "\n\n¿Es para delivery o retiro?",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "view_cart",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default: preguntar de nuevo
|
||||
return {
|
||||
plan: {
|
||||
reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal",
|
||||
next_state: ConversationState.SHIPPING,
|
||||
intent: "other",
|
||||
missing_fields: ["shipping_method"],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
147
src/modules/3-turn-engine/stateHandlers/utils.js
Normal file
147
src/modules/3-turn-engine/stateHandlers/utils.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Utilidades compartidas para los state handlers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Infiere la unidad por defecto basándose en el nombre y categorías del producto
|
||||
*/
|
||||
export function inferDefaultUnit({ name, categories }) {
|
||||
const n = String(name || "").toLowerCase();
|
||||
const cats = Array.isArray(categories) ? categories : [];
|
||||
const hay = (re) =>
|
||||
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
|
||||
if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
|
||||
return "unit";
|
||||
}
|
||||
return "kg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea selección por índice del texto (números o palabras como "primero", "segundo")
|
||||
*/
|
||||
export function parseIndexSelection(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
const m = /\b(\d{1,2})\b/.exec(t);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
|
||||
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
|
||||
if (/\btercera\b|\btercero\b/.test(t)) return 3;
|
||||
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
|
||||
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
|
||||
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
|
||||
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
|
||||
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
|
||||
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
|
||||
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si el usuario pide ver más opciones
|
||||
*/
|
||||
export function isShowMoreRequest(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
return (
|
||||
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
|
||||
/\bmas\s+opciones\b/.test(t) ||
|
||||
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
|
||||
/\bsiguiente(s)?\b/.test(t) ||
|
||||
/\b(no\s+)?hay\s+(otras?|m[aá]s)\b/.test(t) ||
|
||||
/\botras?\s+opciones\b/.test(t) ||
|
||||
/\bqu[eé]\s+m[aá]s\s+hay\b/.test(t) ||
|
||||
/\bver\s+m[aá]s\b/.test(t) ||
|
||||
/\btodas?\s+las?\s+opciones\b/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si el usuario pide ver las opciones disponibles
|
||||
*/
|
||||
export function isShowOptionsRequest(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
return (
|
||||
/\bqu[eé]\s+opciones\b/.test(t) ||
|
||||
/\bcu[aá]les\s+(son|hay|ten[eé]s)\b/.test(t) ||
|
||||
/\bmostr(a|ame)\s+(las\s+)?opciones\b/.test(t) ||
|
||||
/\bver\s+(las\s+)?opciones\b/.test(t) ||
|
||||
/\bqu[eé]\s+hay\b/.test(t) ||
|
||||
/\bqu[eé]\s+ten[eé]s\b/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca un candidato que coincida con el texto del usuario (fuzzy match)
|
||||
*/
|
||||
export function findMatchingCandidate(candidates, text) {
|
||||
if (!candidates?.length || !text) return null;
|
||||
|
||||
const t = String(text).toLowerCase().trim();
|
||||
const words = t.split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const candidate = candidates[i];
|
||||
const name = String(candidate.name || "").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
for (const word of words) {
|
||||
if (name.includes(word)) score += 1;
|
||||
}
|
||||
// Bonus si el texto completo está en el nombre
|
||||
if (name.includes(t)) score += 2;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = { index: i, candidate, score };
|
||||
}
|
||||
}
|
||||
|
||||
// Requiere al menos una palabra que coincida
|
||||
return bestScore > 0 ? bestMatch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si el texto indica un intent de escape (ver carrito, confirmar, etc.)
|
||||
*/
|
||||
export function isEscapeRequest(text) {
|
||||
const t = String(text || "").toLowerCase();
|
||||
return (
|
||||
/\b(que|qué)\s+tengo\b/.test(t) ||
|
||||
/\bmi\s+(carrito|pedido)\b/.test(t) ||
|
||||
/\bver\s+(carrito|pedido)\b/.test(t) ||
|
||||
/\bmostrar\s+(carrito|pedido)\b/.test(t) ||
|
||||
/\blisto\b/.test(t) ||
|
||||
/\bconfirmar?\b/.test(t) ||
|
||||
/\bcancelar?\b/.test(t) ||
|
||||
/\beso\s+(es\s+)?todo\b/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza unidades a formato estándar
|
||||
*/
|
||||
export function normalizeUnit(unit) {
|
||||
if (!unit) return null;
|
||||
const u = String(unit).toLowerCase();
|
||||
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
|
||||
if (u === "g" || u === "gramo" || u === "gramos") return "g";
|
||||
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera la pregunta para pedir cantidad según la unidad
|
||||
*/
|
||||
export function unitAskFor(displayUnit) {
|
||||
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
|
||||
if (displayUnit === "g") return "¿Cuántos gramos querés?";
|
||||
return "¿Cuántos kilos querés?";
|
||||
}
|
||||
46
src/modules/3-turn-engine/stateHandlers/waiting.js
Normal file
46
src/modules/3-turn-engine/stateHandlers/waiting.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Handler para el estado WAITING_WEBHOOKS
|
||||
*/
|
||||
|
||||
import { ConversationState } from "../fsm.js";
|
||||
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||
|
||||
/**
|
||||
* Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago)
|
||||
*/
|
||||
export async function handleWaitingState({ tenantId, text, nlu, order, audit }) {
|
||||
const intent = nlu?.intent || "other";
|
||||
const currentOrder = order || createEmptyOrder();
|
||||
|
||||
// view_cart
|
||||
if (intent === "view_cart") {
|
||||
const cartDisplay = formatCartForDisplay(currentOrder);
|
||||
const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago...";
|
||||
return {
|
||||
plan: {
|
||||
reply: `${cartDisplay}\n\nEstado: ${status}`,
|
||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||
intent: "view_cart",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
const reply = currentOrder.payment_type === "link"
|
||||
? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago."
|
||||
: "Tu pedido está listo. Avisame si necesitás algo más.";
|
||||
|
||||
return {
|
||||
plan: {
|
||||
reply,
|
||||
next_state: ConversationState.WAITING_WEBHOOKS,
|
||||
intent: "other",
|
||||
missing_fields: [],
|
||||
order_action: "none",
|
||||
},
|
||||
decision: { actions: [], order: currentOrder, audit },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user