Redesign: agente tool-calling con DeepSeek (D2-D10 del plan)

Reemplaza el NLU rígido (intent+entities) por un agente LLM con tool-calling
que decide y muta estado en cada turno. Opt-in vía AGENT_TURN_ENGINE=1.
DeepSeek V4 (deepseek-chat) configurado como modelo (OpenAI-compatible).

Arquitectura nueva en src/modules/3-turn-engine/agent/:

- workingMemory.js: arma el JSON contextual que recibe el LLM cada turno
  (cart, pending, last_shown_options, store, customer_profile, history,
  preparsed quantity).
- systemPrompt.js: prompt estático ~70 líneas. Define rol + reglas duras +
  cómo procesar mensajes + cómo escribir el say. Sin enumeración de intents.
- runTurn.js: loop de tool-calling con tool_choice="required". Cap 10 tool
  calls / 20s timeout. Métricas in-memory.
- customerProfile.js: lookup de frequent_items en woo_orders_cache por
  teléfono (chat_id → phone), top 5 últimos 6 meses. Cache 10 min.
- tools/schemas.js: 11 tools (search_catalog, add_to_cart, set_quantity,
  select_candidate, remove_from_cart, set_shipping, set_address,
  confirm_order, pause, escalate_to_human, say).
- tools/executor.js: validación Ajv + dispatch + observación al LLM.
  woo_id se valida contra snapshot — si no existe el agente vuelve a
  search_catalog (anti-halucinación).
- tools/searchCatalog.js: wrappea retrieveCandidates + fallback por
  categoría usando jsonb_array_elements_text del snapshot. Persiste
  last_shown_options automáticamente.
- tools/{addToCart, setQuantity, selectCandidate, removeFromCart,
  setShipping, setAddress, confirmOrder, pause, escalateToHuman}.js:
  side effects atómicos sobre el order.
- quantityParser.js (D1): determinístico, parsea fracciones, frases
  compuestas (media docena, cuarto kilo), numéricos. 46 tests.

FSM extendida (fsm.js): nuevo estado PAUSED (TTL 7d, cart preservado,
"después te digo" → pause tool).

pipeline.js: TTL stale ahora 24h general, 7d si PAUSED, infinito si
AWAITING_HUMAN.

turnEngineV3.js: nuevas flags AGENT_TURN_ENGINE y AGENT_TURN_ENGINE_SHADOW.
Branch a runTurnAgent cuando full o corre en paralelo escribiendo diffs
estructurales en audit_log (entity_type='agent_shadow') para validar
paridad antes de flippar.

Endpoint nuevo: GET /api/metrics/agent → turns, avg_tool_calls, fallback
rate, escalations, pauses, orders_confirmed.

Smoke test E2E con DeepSeek real:
- "hola" → say (2.3s, 1 tool)
- "2kg de vacio" → search → add_to_cart → say (8.8s, 3 tools)
- "media docena de chorizos" → search → say con clarificación (10.3s, 4 tools)
- "listo" → say (3.3s, 1 tool)
- "retiro" → set_shipping → confirm → say (5.1s, 3 tools)
Cart final correcto: 2kg de Vacío. Estado: CART → SHIPPING.

Tests: 238/238 pasando.

D9 (cleanup legacy ~1200 LOC NLU/handlers/replyRewriter) DEFERRED:
se hace después de paridad shadow validada con tráfico real. Hoy
agente coexiste con legacy; default sigue siendo el motor V3.

Plan completo en ~/.claude/plans/ok-creo-que-tiene-humming-sutton.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-02 12:52:47 -03:00
parent 9c69cf8911
commit 03621f16f4
21 changed files with 1414 additions and 11 deletions

View File

@@ -0,0 +1,66 @@
/**
* add_to_cart — agrega un producto resuelto al carrito.
*
* Valida woo_id contra el snapshot (anti-halucinación). Si no existe,
* devuelve error obligando re-search.
*/
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
import { createCartItem } from "../../orderModel.js";
export async function addToCartTool(args, ctx) {
const { woo_id, qty, unit } = args;
// Validar que el producto existe en el snapshot
const lookup = await getSnapshotItemsByIds({
tenantId: ctx.tenantId,
wooProductIds: [woo_id],
});
const found = (lookup?.items || [])[0];
if (!found) {
return {
ok: false,
error: "woo_id_unknown",
hint: "Volvé a llamar search_catalog para obtener un woo_id válido.",
};
}
// Crear item de carrito
const newItem = createCartItem({
woo_id,
qty,
unit,
name: found.name,
price: found.price ?? null,
});
// Si ya existe el woo_id en el cart, sumamos cantidad
const cart = ctx.order.cart || [];
const existingIdx = cart.findIndex((c) => Number(c.woo_id) === Number(woo_id));
let nextCart;
if (existingIdx >= 0) {
nextCart = cart.map((c, i) =>
i === existingIdx ? { ...c, qty: (c.qty || 0) + qty, unit: c.unit || unit } : c
);
} else {
nextCart = [...cart, newItem];
}
ctx.order = { ...ctx.order, cart: nextCart };
// Enqueue add_to_cart action para SSE/UI (reutiliza shape existente)
ctx.pending_actions.push({ type: "add_to_cart", payload: newItem });
// Reset failed_searches si hubiera
if (ctx.order.failed_searches) {
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
}
// Limpiar last_shown_options — ya resolvió la elección
ctx.last_shown_options = [];
return {
ok: true,
cart_size: nextCart.length,
added: { woo_id, name: found.name, qty, unit },
};
}

View File

@@ -0,0 +1,32 @@
/**
* confirm_order — emite create_order si hay cart + shipping completo.
*/
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
export async function confirmOrderTool(_args, ctx) {
if (!hasCartItems(ctx.order)) {
return { ok: false, error: "empty_cart", hint: "Pedile al cliente que agregue productos antes de confirmar." };
}
if (!hasShippingInfo(ctx.order)) {
return {
ok: false,
error: "shipping_missing",
hint:
ctx.order.is_delivery == null
? "Falta saber si es delivery o pickup. Llamá set_shipping."
: "Falta dirección. Llamá set_address.",
};
}
// Idempotencia: si ya existe create_order encolado, no duplicar
const already = ctx.pending_actions.some((a) => a.type === "create_order");
if (!already) {
ctx.pending_actions.push({ type: "create_order", payload: { source: "wa_bot" } });
}
return {
ok: true,
cart_size: (ctx.order.cart || []).length,
is_delivery: !!ctx.order.is_delivery,
address: ctx.order.shipping_address || null,
};
}

View File

@@ -0,0 +1,16 @@
/**
* escalate_to_human — pasa la conversación a awaiting_human.
* El registro real en human_takeovers se crea downstream en pipeline.js
* vía la action "request_human_takeover".
*/
export async function escalateToHumanTool(args, ctx) {
const { reason } = args;
ctx.awaiting_human = true;
ctx.awaiting_human_reason = String(reason || "unspecified");
ctx.pending_actions.push({
type: "request_human_takeover",
payload: { reason: ctx.awaiting_human_reason, source: "agent" },
});
return { ok: true, reason: ctx.awaiting_human_reason };
}

View File

@@ -0,0 +1,102 @@
/**
* Executor: parsea + valida + ejecuta tool calls del agente.
* Devuelve `obs` (objeto serializable) que se pushea como `tool` message.
*
* Convenciones:
* - obs.ok: true|false
* - obs.error: string si !ok
* - obs.terminal: true si esta tool finaliza el turno (say, pause, escalate)
*/
import Ajv from "ajv";
import { TOOL_SCHEMAS } from "./schemas.js";
import { searchCatalogTool } from "./searchCatalog.js";
import { addToCartTool } from "./addToCart.js";
import { setQuantityTool } from "./setQuantity.js";
import { selectCandidateTool } from "./selectCandidate.js";
import { removeFromCartTool } from "./removeFromCart.js";
import { setShippingTool } from "./setShipping.js";
import { setAddressTool } from "./setAddress.js";
import { confirmOrderTool } from "./confirmOrder.js";
import { pauseTool } from "./pause.js";
import { escalateToHumanTool } from "./escalateToHuman.js";
const ajv = new Ajv({ allErrors: true, strict: false });
// Compilar validators una vez
const VALIDATORS = {};
for (const t of TOOL_SCHEMAS) {
VALIDATORS[t.function.name] = ajv.compile(t.function.parameters);
}
const TOOLS = {
search_catalog: searchCatalogTool,
add_to_cart: addToCartTool,
set_quantity: setQuantityTool,
select_candidate: selectCandidateTool,
remove_from_cart: removeFromCartTool,
set_shipping: setShippingTool,
set_address: setAddressTool,
confirm_order: confirmOrderTool,
pause: pauseTool,
escalate_to_human: escalateToHumanTool,
// `say` se maneja inline (asigna ctx.say_text y termina el turno)
};
export async function executeToolCall(call, ctx) {
const t0 = Date.now();
const name = call?.function?.name;
const argsRaw = call?.function?.arguments || "{}";
let args;
try {
args = typeof argsRaw === "string" ? JSON.parse(argsRaw) : argsRaw;
} catch (e) {
return {
ok: false,
error: `invalid_json_args: ${String(e?.message || e)}`,
duration_ms: Date.now() - t0,
};
}
// `say` es especial: termina el turno
if (name === "say") {
const validator = VALIDATORS.say;
if (!validator(args)) {
return { ok: false, error: "say_args_invalid", details: validator.errors, duration_ms: Date.now() - t0 };
}
ctx.say_text = args.text;
return { ok: true, terminal: true, duration_ms: Date.now() - t0 };
}
const validator = VALIDATORS[name];
if (!validator) {
return { ok: false, error: `unknown_tool: ${name}`, duration_ms: Date.now() - t0 };
}
if (!validator(args)) {
return {
ok: false,
error: "args_schema_invalid",
details: validator.errors,
tool: name,
duration_ms: Date.now() - t0,
};
}
const handler = TOOLS[name];
if (!handler) {
return { ok: false, error: `tool_not_implemented: ${name}`, duration_ms: Date.now() - t0 };
}
try {
const result = await handler(args, ctx);
return { ...result, duration_ms: Date.now() - t0 };
} catch (err) {
return {
ok: false,
error: `tool_error: ${String(err?.message || err)}`,
tool: name,
duration_ms: Date.now() - t0,
};
}
}

View File

@@ -0,0 +1,14 @@
/**
* pause — marca la conversación como pausada (TTL 7d).
*
* El cart NO se limpia. Cuando el cliente vuelva, sale de paused y sigue.
*/
const PAUSE_TTL_MS = 7 * 24 * 3600 * 1000;
export async function pauseTool(args, ctx) {
const { reason = "user_paused" } = args;
ctx.paused = true;
ctx.paused_until = new Date(Date.now() + PAUSE_TTL_MS).toISOString();
return { ok: true, paused_until: ctx.paused_until, reason, terminal: false };
}

View File

@@ -0,0 +1,43 @@
/**
* remove_from_cart — quita un producto por woo_id (string numérico) o por
* substring del nombre.
*/
import { removeCartItem } from "../../orderModel.js";
export async function removeFromCartTool(args, ctx) {
const { target } = args;
const t = String(target || "").trim();
if (!t) return { ok: false, error: "empty_target" };
// Si es un número puro, intentar match por woo_id directo
const asNumber = /^\d+$/.test(t) ? Number(t) : null;
let removed = null;
let nextOrder = ctx.order;
if (asNumber != null) {
const cart = ctx.order.cart || [];
const idx = cart.findIndex((c) => Number(c.woo_id) === asNumber);
if (idx >= 0) {
removed = cart[idx];
nextOrder = { ...ctx.order, cart: cart.filter((_, i) => i !== idx) };
}
}
// Match por nombre
if (!removed) {
const result = removeCartItem(ctx.order, t);
if (result?.removed) {
removed = result.removed;
nextOrder = result.order;
}
}
if (!removed) {
return { ok: false, error: "not_found_in_cart", target: t };
}
ctx.order = nextOrder;
ctx.pending_actions.push({ type: "remove_from_cart", payload: { removed } });
return { ok: true, removed: { woo_id: removed.woo_id, name: removed.name }, cart_size: (nextOrder.cart || []).length };
}

View File

@@ -0,0 +1,176 @@
/**
* JSON Schemas de los tools que el agente puede invocar. Formato
* OpenAI/DeepSeek (function calling).
*
* El executor valida los args con Ajv y devuelve error obligando re-llamada
* si falla.
*/
export const TOOL_SCHEMAS = [
{
type: "function",
function: {
name: "search_catalog",
description:
"Busca productos en el catálogo. Devuelve top candidatos con woo_id, nombre, precio, unidad de venta. Llamala SIEMPRE antes de cualquier add_to_cart si no tenés el woo_id.",
parameters: {
type: "object",
additionalProperties: false,
required: ["query"],
properties: {
query: { type: "string", minLength: 2, description: "Término de búsqueda libre, lo que dijo el cliente." },
hint_category: { type: "string", description: "Categoría heurística para fallback (ej. 'parrilla', 'embutidos')." },
limit: { type: "integer", minimum: 1, maximum: 10 },
},
},
},
},
{
type: "function",
function: {
name: "add_to_cart",
description: "Agrega un producto resuelto al carrito.",
parameters: {
type: "object",
additionalProperties: false,
required: ["woo_id", "qty", "unit"],
properties: {
woo_id: { type: "integer", description: "Woo product ID exacto del producto." },
qty: { type: "number", exclusiveMinimum: 0 },
unit: { type: "string", enum: ["kg", "g", "unit"] },
},
},
},
},
{
type: "function",
function: {
name: "set_quantity",
description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.",
parameters: {
type: "object",
additionalProperties: false,
required: ["pending_id", "qty", "unit"],
properties: {
pending_id: { type: "string" },
qty: { type: "number", exclusiveMinimum: 0 },
unit: { type: "string", enum: ["kg", "g", "unit"] },
},
},
},
},
{
type: "function",
function: {
name: "select_candidate",
description: "Resuelve un pending NEEDS_TYPE eligiendo uno de los candidatos mostrados (last_shown_options).",
parameters: {
type: "object",
additionalProperties: false,
required: ["pending_id", "woo_id"],
properties: {
pending_id: { type: "string" },
woo_id: { type: "integer" },
},
},
},
},
{
type: "function",
function: {
name: "remove_from_cart",
description: "Quita un producto del carrito por woo_id o nombre/query.",
parameters: {
type: "object",
additionalProperties: false,
required: ["target"],
properties: {
target: { type: "string", description: "woo_id como string o nombre del producto a quitar." },
},
},
},
},
{
type: "function",
function: {
name: "set_shipping",
description: "Setea el método de envío (delivery o pickup).",
parameters: {
type: "object",
additionalProperties: false,
required: ["method"],
properties: {
method: { type: "string", enum: ["delivery", "pickup"] },
},
},
},
},
{
type: "function",
function: {
name: "set_address",
description: "Setea la dirección de entrega y valida zona. Si está fuera de zona devuelve error.",
parameters: {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: { type: "string", minLength: 5 },
},
},
},
},
{
type: "function",
function: {
name: "confirm_order",
description: "Confirma el pedido. Requiere cart no vacío y shipping completo (pickup o delivery+address). Emite acción create_order.",
parameters: { type: "object", additionalProperties: false, properties: {} },
},
},
{
type: "function",
function: {
name: "pause",
description: "Pausa la conversación cuando el cliente dice 'después te digo' o equivalente. Mantiene el cart 7 días.",
parameters: {
type: "object",
additionalProperties: false,
required: ["reason"],
properties: {
reason: { type: "string", enum: ["user_paused", "user_busy", "needs_to_check"] },
},
},
},
},
{
type: "function",
function: {
name: "escalate_to_human",
description: "Escala a un humano (quejas, dudas de pago/factura, urgencias, no podemos resolver).",
parameters: {
type: "object",
additionalProperties: false,
required: ["reason"],
properties: {
reason: { type: "string", minLength: 3 },
},
},
},
},
{
type: "function",
function: {
name: "say",
description: "Texto final que se envía al cliente. ÚLTIMO tool del turno SIEMPRE.",
parameters: {
type: "object",
additionalProperties: false,
required: ["text"],
properties: {
text: { type: "string", minLength: 1, maxLength: 600 },
},
},
},
},
];

View File

@@ -0,0 +1,106 @@
/**
* search_catalog tool — wrappea retrieveCandidates con fallback por categoría.
*
* Side effects: muta `ctx.last_shown_options` con el top de candidatos para
* que select_candidate pueda resolver "el segundo" en turnos posteriores.
*/
import { retrieveCandidates } from "../../catalogRetrieval.js";
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
import { pool } from "../../../shared/db/pool.js";
const MIN_GOOD_SCORE = 0.4;
function summarizeCandidate(c, idx) {
return {
index: idx + 1,
woo_id: c.woo_product_id,
name: c.name,
price: c.price ?? null,
sell_unit: c.sell_unit || null,
score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null,
};
}
export async function searchCatalogTool(args, ctx) {
const { query, hint_category = null, limit = 5 } = args;
// 1) Búsqueda directa con catalogRetrieval (pg_trgm + alias + snapshot)
const result = await retrieveCandidates({
tenantId: ctx.tenantId,
query,
limit: Math.max(limit, 5),
});
let candidates = result?.candidates || [];
// 2) Fallback por categoría si la búsqueda directa rinde poco
let usedFallback = null;
if (
(candidates.length === 0 ||
(candidates[0]?._score || 0) < MIN_GOOD_SCORE) &&
hint_category
) {
const byCategory = await searchByCategory({
tenantId: ctx.tenantId,
category: hint_category,
limit,
});
if (byCategory.length > 0) {
usedFallback = "category";
candidates = byCategory;
}
}
// Recortar al limit final
const top = candidates.slice(0, limit).map(summarizeCandidate);
// Guardar last_shown_options si hay >1 (para select_candidate posterior)
if (top.length > 1) {
ctx.last_shown_options = top.map((t) => ({
index: t.index,
woo_id: t.woo_id,
name: t.name,
price: t.price,
}));
}
return {
ok: true,
query,
candidates: top,
used_fallback: usedFallback,
count: top.length,
};
}
async function searchByCategory({ tenantId, category, limit }) {
// Buscar productos cuya categories JSONB contenga el name/slug indicado.
// Sin nuevas tablas — explota lo que ya está en woo_products_snapshot.categories.
const sql = `
SELECT woo_product_id, name, sku, slug, price, stock_status, stock_qty,
categories, sell_unit, payload
FROM woo_products_snapshot
WHERE tenant_id = $1
AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(categories) cat
WHERE LOWER(cat) LIKE '%' || LOWER($2) || '%'
)
AND COALESCE(stock_status, 'instock') != 'outofstock'
ORDER BY name ASC
LIMIT $3
`;
try {
const { rows } = await pool.query(sql, [tenantId, category, limit]);
return rows.map((r) => ({
woo_product_id: r.woo_product_id,
name: r.name,
price: r.price,
sell_unit: r.sell_unit,
_score: 0.5, // score sintético para que pase MIN_GOOD_SCORE en el caller
}));
} catch (err) {
// Fallback: snapshot search por texto plano
const r = await searchSnapshotItems({ tenantId, q: category, limit });
return r?.items || [];
}
}

View File

@@ -0,0 +1,72 @@
/**
* select_candidate — resuelve un pending NEEDS_TYPE eligiendo woo_id.
*
* Si el pending tenía `requested_qty` ya, lo promueve directo a READY → cart.
* Sino lo deja en NEEDS_QUANTITY esperando set_quantity.
*/
import {
updatePendingItem,
moveReadyToCart,
PendingStatus,
} from "../../orderModel.js";
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
export async function selectCandidateTool(args, ctx) {
const { pending_id, woo_id } = args;
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
if (!pending) {
return { ok: false, error: "pending_not_found" };
}
// Buscar el producto seleccionado en el snapshot para nombre/unit/precio
const lookup = await getSnapshotItemsByIds({
tenantId: ctx.tenantId,
wooProductIds: [woo_id],
});
const found = (lookup?.items || [])[0];
if (!found) {
return {
ok: false,
error: "woo_id_unknown",
hint: "El woo_id no existe en el catálogo. Probá con search_catalog.",
};
}
const sellsByWeight =
!found.sell_unit || !["unit", "unidad"].includes(found.sell_unit);
const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit";
const hasRequestedQty =
pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0;
const finalQty = hasRequestedQty ? pending.requested_qty : null;
const finalUnit = pending.requested_unit || displayUnit;
const needsQty = sellsByWeight && !hasRequestedQty;
const updated = updatePendingItem(ctx.order, pending_id, {
selected_woo_id: woo_id,
selected_name: found.name,
selected_price: found.price ?? null,
selected_unit: displayUnit,
candidates: [],
qty: needsQty ? null : finalQty,
unit: finalUnit,
status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
});
ctx.order = moveReadyToCart(updated);
if (!needsQty) {
ctx.pending_actions.push({
type: "add_to_cart",
payload: { woo_id, qty: finalQty, unit: finalUnit },
});
ctx.last_shown_options = [];
}
return {
ok: true,
selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight },
needs_quantity: needsQty,
cart_size: (ctx.order.cart || []).length,
};
}

View File

@@ -0,0 +1,36 @@
/**
* set_address — fija dirección y valida zona.
*/
import { checkAddressInZone } from "../../storeContext.js";
export async function setAddressTool(args, ctx) {
const { text } = args;
const address = String(text || "").trim();
if (address.length < 5) return { ok: false, error: "address_too_short" };
// Validar zona si hay zonas cargadas
const zoneCheck = checkAddressInZone({
address,
storeConfig: ctx.storeConfig,
});
if (!zoneCheck.inZone) {
return {
ok: false,
error: "out_of_zone",
reason: zoneCheck.reason,
available_zones: zoneCheck.zones,
hint: "Pedile al cliente otra dirección o ofrecele pickup.",
};
}
// Si no había is_delivery seteado, asumir delivery=true (le dieron dirección)
const is_delivery = ctx.order.is_delivery == null ? true : ctx.order.is_delivery;
ctx.order = { ...ctx.order, shipping_address: address, is_delivery };
return {
ok: true,
address,
in_zone: true,
matched_zone: zoneCheck.matched || null,
};
}

View File

@@ -0,0 +1,36 @@
/**
* set_quantity — completa la cantidad de un pending NEEDS_QUANTITY y lo
* promueve a READY → cart.
*/
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
export async function setQuantityTool(args, ctx) {
const { pending_id, qty, unit } = args;
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
if (!pending) {
return { ok: false, error: "pending_not_found", hint: "Verificá el pending_id contra working_memory.order.pending." };
}
if (!pending.selected_woo_id) {
return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." };
}
const updated = updatePendingItem(ctx.order, pending_id, {
qty,
unit,
status: PendingStatus.READY,
});
ctx.order = moveReadyToCart(updated);
ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id: pending.selected_woo_id, qty, unit } });
ctx.last_shown_options = [];
if (ctx.order.failed_searches) {
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
}
return {
ok: true,
promoted: { name: pending.selected_name, qty, unit },
cart_size: (ctx.order.cart || []).length,
};
}

View File

@@ -0,0 +1,12 @@
/**
* set_shipping — fija el método de envío.
*/
export async function setShippingTool(args, ctx) {
const { method } = args;
if (method !== "delivery" && method !== "pickup") {
return { ok: false, error: "invalid_method" };
}
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
return { ok: true, method, requires_address: method === "delivery" && !ctx.order.shipping_address };
}