last_delivery: reusar dirección/zona del último pedido + retitular header
Persistencia: cuando confirm_order encola create_order y la orden tiene
shipping/zona/window, runTurn snapshot a context.last_delivery
{is_delivery, shipping_address, matched_zone, pending_location, delivery_window}.
pipeline preserva ese campo cuando resetea por stale (>24h), igual que
external_customer_id.
Agente lo ve via working_memory.last_delivery en cada turno.
Nueva tool reuse_last_delivery() que copia shipping_address + matched_zone
(+ pending_location) al order actual. Pickup-only sólo setea is_delivery=false.
systemPrompt: instrucciones para que el bot proactivamente ofrezca "te lo
mandamos al mismo lugar que la última vez (dirección, zona, $)" cuando
last_delivery existe y todavía no se eligió método de envío. Cliente puede
aceptar (reuse_last_delivery) o pedir otra dirección/retiro. delivery_window
NO se asume — siempre se vuelve a preguntar día/hora.
Smoke E2E: cliente recurrente con conversación stale 25h+
- 1ra orden: 1kg vacío → location → mar 12h → confirma.
- DB: context.last_delivery con zona Centro Test + dirección + ventana.
- 2da orden: "hola, 500g bondiola" → bot: "¿al mismo lugar (Av. Corrientes
1234, Centro, $1.500)?" → "sí" → "¿qué día? La última fue mar 12h, puede
ser otro" → "jueves 11hs" → orden cerrada sin re-pedir pin.
Header: "Bot Ops Console" → "Piaf Console" (index.html + ops-shell).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,7 +86,7 @@ class OpsShell extends HTMLElement {
|
|||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<h1>Bot Ops Console</h1>
|
<h1>Piaf Console</h1>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a class="nav-btn active" href="/home" data-view="home">Home</a>
|
<a class="nav-btn active" href="/home" data-view="home">Home</a>
|
||||||
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
|
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Bot Ops Console</title>
|
<title>Piaf Console</title>
|
||||||
<link rel="stylesheet" href="/styles/theme.css" />
|
<link rel="stylesheet" href="/styles/theme.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -180,13 +180,14 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
|||||||
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
// Conversación nueva: resetear carrito pero mantener datos del cliente
|
// Conversación nueva: resetear carrito pero mantener datos del cliente
|
||||||
|
// (external_customer_id) y la "última entrega" para que el bot pueda
|
||||||
|
// ofrecer "te lo mando al mismo lugar que la otra vez?".
|
||||||
reducedContext = {
|
reducedContext = {
|
||||||
external_customer_id: reducedContext.external_customer_id,
|
external_customer_id: reducedContext.external_customer_id,
|
||||||
// Resetear order y pending
|
last_delivery: reducedContext.last_delivery || null,
|
||||||
order: null,
|
order: null,
|
||||||
order_basket: null,
|
order_basket: null,
|
||||||
pending_items: null,
|
pending_items: null,
|
||||||
// Marcar que fue reseteado
|
|
||||||
_reset_reason: "stale",
|
_reset_reason: "stale",
|
||||||
_reset_at: new Date().toISOString(),
|
_reset_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ export async function runTurnAgent({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// last_delivery del cliente: snapshot de la última orden confirmada
|
||||||
|
// (dirección + zona + day/time). El bot puede ofrecérsela proactivamente
|
||||||
|
// o el cliente puede pedir "lo mismo de la última vez".
|
||||||
|
const lastDelivery = (prev_context && typeof prev_context === "object" && prev_context.last_delivery) || null;
|
||||||
|
|
||||||
// Construir working memory
|
// Construir working memory
|
||||||
const wm = buildWorkingMemory({
|
const wm = buildWorkingMemory({
|
||||||
text,
|
text,
|
||||||
@@ -124,6 +129,7 @@ export async function runTurnAgent({
|
|||||||
storeConfig,
|
storeConfig,
|
||||||
customerProfile,
|
customerProfile,
|
||||||
lastShownOptions,
|
lastShownOptions,
|
||||||
|
lastDelivery,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Estado mutable que los tools mutan
|
// Estado mutable que los tools mutan
|
||||||
@@ -134,6 +140,7 @@ export async function runTurnAgent({
|
|||||||
pending_actions: [],
|
pending_actions: [],
|
||||||
last_shown_options: [...lastShownOptions],
|
last_shown_options: [...lastShownOptions],
|
||||||
storeConfig,
|
storeConfig,
|
||||||
|
last_delivery: lastDelivery,
|
||||||
say_text: null,
|
say_text: null,
|
||||||
paused: false,
|
paused: false,
|
||||||
paused_until: order.paused_until ?? null,
|
paused_until: order.paused_until ?? null,
|
||||||
@@ -320,10 +327,27 @@ function toBasketItem(item) {
|
|||||||
|
|
||||||
function buildContextPatch(ctx) {
|
function buildContextPatch(ctx) {
|
||||||
const order = ctx.order || createEmptyOrder();
|
const order = ctx.order || createEmptyOrder();
|
||||||
|
|
||||||
|
// Si hay create_order encolado y la orden está completa, snapshot del
|
||||||
|
// "último envío" para reusar en próximas conversaciones.
|
||||||
|
let last_delivery = ctx.last_delivery || null;
|
||||||
|
const isClosing = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||||
|
if (isClosing && (order.shipping_address || order.matched_zone || order.is_delivery === false)) {
|
||||||
|
last_delivery = {
|
||||||
|
is_delivery: !!order.is_delivery,
|
||||||
|
shipping_address: order.shipping_address || null,
|
||||||
|
matched_zone: order.matched_zone || null,
|
||||||
|
pending_location: order.pending_location || null,
|
||||||
|
delivery_window: order.delivery_window || null,
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Persist the full order object so pending_location/matched_zone/delivery_window
|
// Persist the full order object so pending_location/matched_zone/delivery_window
|
||||||
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
|
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
|
||||||
order,
|
order,
|
||||||
|
last_delivery,
|
||||||
order_basket: { items: (order.cart || []).map(toBasketItem) },
|
order_basket: { items: (order.cart || []).map(toBasketItem) },
|
||||||
pending_items: (order.pending || []).map((p) => ({
|
pending_items: (order.pending || []).map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ ENVÍO Y ZONAS:
|
|||||||
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
|
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
|
||||||
o el schedule.
|
o el schedule.
|
||||||
|
|
||||||
|
CLIENTES QUE VUELVEN (last_delivery):
|
||||||
|
- Si working_memory.last_delivery existe (no null), el cliente ya hizo un pedido
|
||||||
|
antes y tenemos guardado: shipping_address, matched_zone, delivery_window.
|
||||||
|
- Cuando el cliente está armando un pedido nuevo y todavía no eligió método de
|
||||||
|
envío ni dio dirección, ofrecele proactivamente la opción de repetir:
|
||||||
|
"¿Te lo mandamos al mismo lugar que la última vez (Av. Corrientes 1234,
|
||||||
|
zona Centro, $1.500)? También podés pedirme otra dirección o retiro."
|
||||||
|
- Si confirma ("sí", "dale", "el mismo lugar", "como siempre"), llamá
|
||||||
|
reuse_last_delivery — copia dirección + zona y se salta el pedido del pin.
|
||||||
|
Después confirmá el día/horario (puede ser distinto al de la última).
|
||||||
|
- Si dice que no, o pide otro lugar, seguí el flujo normal (set_shipping →
|
||||||
|
pedir pin → set_address → set_delivery_window).
|
||||||
|
- last_delivery.delivery_window es sólo referencia, NO lo asumas como elegido
|
||||||
|
para esta orden. Preguntá día/hora aunque vayas a reutilizar el lugar.
|
||||||
|
|
||||||
LIMITES TÉCNICOS:
|
LIMITES TÉCNICOS:
|
||||||
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
|
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
|
||||||
redundantes — si una query ya devolvió X candidatos, NO la repitas.
|
redundantes — si una query ya devolvió X candidatos, NO la repitas.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { removeFromCartTool } from "./removeFromCart.js";
|
|||||||
import { setShippingTool } from "./setShipping.js";
|
import { setShippingTool } from "./setShipping.js";
|
||||||
import { setAddressTool } from "./setAddress.js";
|
import { setAddressTool } from "./setAddress.js";
|
||||||
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
|
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
|
||||||
|
import { reuseLastDeliveryTool } from "./reuseLastDelivery.js";
|
||||||
import { confirmOrderTool } from "./confirmOrder.js";
|
import { confirmOrderTool } from "./confirmOrder.js";
|
||||||
import { pauseTool } from "./pause.js";
|
import { pauseTool } from "./pause.js";
|
||||||
import { escalateToHumanTool } from "./escalateToHuman.js";
|
import { escalateToHumanTool } from "./escalateToHuman.js";
|
||||||
@@ -39,6 +40,7 @@ const TOOLS = {
|
|||||||
set_shipping: setShippingTool,
|
set_shipping: setShippingTool,
|
||||||
set_address: setAddressTool,
|
set_address: setAddressTool,
|
||||||
set_delivery_window: setDeliveryWindowTool,
|
set_delivery_window: setDeliveryWindowTool,
|
||||||
|
reuse_last_delivery: reuseLastDeliveryTool,
|
||||||
confirm_order: confirmOrderTool,
|
confirm_order: confirmOrderTool,
|
||||||
pause: pauseTool,
|
pause: pauseTool,
|
||||||
escalate_to_human: escalateToHumanTool,
|
escalate_to_human: escalateToHumanTool,
|
||||||
|
|||||||
52
src/modules/3-turn-engine/agent/tools/reuseLastDelivery.js
Normal file
52
src/modules/3-turn-engine/agent/tools/reuseLastDelivery.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* reuse_last_delivery — copia los datos de envío de la última orden del cliente
|
||||||
|
* (working_memory.last_delivery) al order actual: shipping_address, matched_zone,
|
||||||
|
* is_delivery (y opcionalmente pending_location + delivery_window).
|
||||||
|
*
|
||||||
|
* El agente lo usa cuando proactivamente ofrece "te lo mandamos al mismo lugar
|
||||||
|
* que la última vez?" y el cliente confirma. Si no hay last_delivery, devuelve
|
||||||
|
* error y el agente pide la dirección/ubicación de cero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function reuseLastDeliveryTool(_args, ctx) {
|
||||||
|
const last = ctx.last_delivery;
|
||||||
|
if (!last) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no_last_delivery",
|
||||||
|
hint: "El cliente no tiene una entrega previa registrada. Pedile la ubicación de cero.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pickup-only no necesita copiar nada de envío.
|
||||||
|
if (last.is_delivery === false) {
|
||||||
|
ctx.order = { ...ctx.order, is_delivery: false };
|
||||||
|
return { ok: true, is_delivery: false, hint: "El cliente la última vez retiró por el local." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery: si no hay matched_zone tampoco podemos reusar (no se cerró bien).
|
||||||
|
if (!last.matched_zone) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no_zone_in_last_delivery",
|
||||||
|
hint: "La última entrega no tenía zona registrada. Pedí la ubicación al cliente.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.order = {
|
||||||
|
...ctx.order,
|
||||||
|
is_delivery: true,
|
||||||
|
shipping_address: last.shipping_address || ctx.order.shipping_address || null,
|
||||||
|
matched_zone: last.matched_zone,
|
||||||
|
// Reusar también la ubicación si la tenemos (evita re-pedir el pin).
|
||||||
|
pending_location: last.pending_location || ctx.order.pending_location || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
is_delivery: true,
|
||||||
|
shipping_address: ctx.order.shipping_address,
|
||||||
|
matched_zone: last.matched_zone,
|
||||||
|
last_delivery_window: last.delivery_window || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -127,6 +127,17 @@ export const TOOL_SCHEMAS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "reuse_last_delivery",
|
||||||
|
description:
|
||||||
|
"Reusa los datos de envío de la última orden del cliente (working_memory.last_delivery): " +
|
||||||
|
"dirección, zona y ubicación. Llamala cuando ofrezcas 'te lo mandamos al mismo lugar que la otra vez?' " +
|
||||||
|
"y el cliente confirme. Si no hay last_delivery devuelve error y tenés que pedir la ubicación de cero.",
|
||||||
|
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function buildWorkingMemory({
|
|||||||
storeConfig = {},
|
storeConfig = {},
|
||||||
customerProfile = null,
|
customerProfile = null,
|
||||||
lastShownOptions = [],
|
lastShownOptions = [],
|
||||||
|
lastDelivery = null,
|
||||||
}) {
|
}) {
|
||||||
const storeVars = buildStoreContextVars(storeConfig);
|
const storeVars = buildStoreContextVars(storeConfig);
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export function buildWorkingMemory({
|
|||||||
last_shown_options,
|
last_shown_options,
|
||||||
paused_until: order.paused_until ?? null,
|
paused_until: order.paused_until ?? null,
|
||||||
customer_profile: customerProfile,
|
customer_profile: customerProfile,
|
||||||
|
last_delivery: lastDelivery, // null si es 1er pedido
|
||||||
history,
|
history,
|
||||||
user_message: text || "",
|
user_message: text || "",
|
||||||
preparsed,
|
preparsed,
|
||||||
|
|||||||
Reference in New Issue
Block a user