diff --git a/TODO.md b/TODO.md index 3798473..7a12046 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,8 @@ # TODOs -- Integrar WooCommerce real en `src/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env). +- Integrar WooCommerce real en `src/modules/2-identity/services/woo.js` (reemplazar stub `createWooCustomer` con llamadas a la API y manejo de errores; usar creds/config desde env). - Pipeline: cuando Woo devuelva el cliente real, mantener/actualizar el mapping en `wa_identity_map` vía `upsertWooCustomerMap`. -- Conectar con OpenAI en `src/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`. +- Conectar con OpenAI en `src/modules/2-identity/services/pipeline.js` usando `llmInput` y validar el output con esquema (Zod) antes de guardar el run. (Hecho) — env: `OPENAI_API_KEY`, opcional `OPENAI_MODEL`. - (Opcional) Endpoint interno para forzar/upsert de mapping Woo ↔ wa_chat_id, reutilizando repo/woo service. - Revisar manejo de multi-tenant en simulador/UI (instance/tenant_key) y asegurar consistencia en `resolveTenantId`/webhooks. - Enterprise: mover credenciales de Woo (u otras tiendas) a secret manager (Vault/AWS SM/etc.), solo referenciarlas desde DB por clave/ID; auditar acceso a secretos y mapping; soportar rotación de keys. diff --git a/db/migrations/20260114132000_woo_products_snapshot.sql b/db/migrations/20260114132000_woo_products_snapshot.sql new file mode 100644 index 0000000..a7cdcb1 --- /dev/null +++ b/db/migrations/20260114132000_woo_products_snapshot.sql @@ -0,0 +1,54 @@ +-- migrate:up +create table if not exists woo_snapshot_runs ( + id bigserial primary key, + tenant_id uuid not null references tenants(id) on delete cascade, + source text not null, -- csv | api | webhook + total_items integer not null default 0, + created_at timestamptz not null default now() +); + +create table if not exists woo_products_snapshot ( + tenant_id uuid not null references tenants(id) on delete cascade, + woo_id integer not null, + type text not null, -- simple | variable | variation + parent_id integer null, + name text not null, + slug text null, + status text null, + catalog_visibility text null, + price_regular numeric(12,2) null, + price_sale numeric(12,2) null, + price_current numeric(12,2) null, + stock_status text null, + stock_qty integer null, + backorders text null, + categories jsonb not null default '[]'::jsonb, + tags jsonb not null default '[]'::jsonb, + attributes_normalized jsonb not null default '{}'::jsonb, + date_modified timestamptz null, + run_id bigint null references woo_snapshot_runs(id) on delete set null, + raw jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now(), + primary key (tenant_id, woo_id) +); + +create index if not exists woo_snapshot_tenant_type_idx + on woo_products_snapshot (tenant_id, type); + +create index if not exists woo_snapshot_tenant_parent_idx + on woo_products_snapshot (tenant_id, parent_id); + +create index if not exists woo_snapshot_tenant_status_idx + on woo_products_snapshot (tenant_id, status); + +create or replace view sellable_items as +select * +from woo_products_snapshot +where lower(type) in ('simple', 'variation') + and coalesce(lower(status), 'publish') = 'publish' + and coalesce(lower(catalog_visibility), 'visible') not in ('hidden'); + +-- migrate:down +drop view if exists sellable_items; +drop table if exists woo_products_snapshot; +drop table if exists woo_snapshot_runs; diff --git a/db/migrations/20260115123000_drop_woo_products_cache.sql b/db/migrations/20260115123000_drop_woo_products_cache.sql new file mode 100644 index 0000000..0553a4a --- /dev/null +++ b/db/migrations/20260115123000_drop_woo_products_cache.sql @@ -0,0 +1,17 @@ +-- migrate:up +drop table if exists woo_products_cache; + +-- migrate:down +create table if not exists woo_products_cache ( + tenant_id uuid not null references tenants(id) on delete cascade, + woo_product_id integer not null, + name text not null, + sku text, + price numeric(12,2), + currency text, + refreshed_at timestamptz not null default now(), + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (tenant_id, woo_product_id) +); diff --git a/index.js b/index.js index c7e73b1..ca4acfc 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ import "dotenv/config"; -import { ensureTenant } from "./src/db/repo.js"; +import { ensureTenant } from "./src/modules/2-identity/db/repo.js"; import { createApp } from "./src/app.js"; async function configureUndiciDispatcher() { diff --git a/package-lock.json b/package-lock.json index f4392c0..7c81145 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "ajv": "^8.17.1", "cors": "^2.8.5", + "csv-parse": "^6.1.0", "dotenv": "^17.2.3", "express": "^4.19.2", "openai": "^6.15.0", @@ -357,6 +358,12 @@ "node": ">= 0.10" } }, + "node_modules/csv-parse": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", + "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", + "license": "MIT" + }, "node_modules/dbmate": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz", diff --git a/package.json b/package.json index 3b12cdd..d365668 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "ajv": "^8.17.1", "cors": "^2.8.5", + "csv-parse": "^6.1.0", "dotenv": "^17.2.3", "express": "^4.19.2", "openai": "^6.15.0", diff --git a/scripts/import-woo-snapshot.mjs b/scripts/import-woo-snapshot.mjs new file mode 100644 index 0000000..36d694d --- /dev/null +++ b/scripts/import-woo-snapshot.mjs @@ -0,0 +1,222 @@ +import fs from "fs"; +import path from "path"; +import { parse } from "csv-parse/sync"; +import { pool } from "../src/modules/2-identity/db/pool.js"; + +function parseArgs() { + const args = process.argv.slice(2); + const out = { file: null, tenantKey: null, replace: true }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--file") out.file = args[++i]; + else if (a === "--tenant-key") out.tenantKey = args[++i]; + else if (a === "--no-replace") out.replace = false; + } + if (!out.file) { + throw new Error("Usage: node scripts/import-woo-snapshot.mjs --file [--tenant-key ] [--no-replace]"); + } + return out; +} + +function parseNumber(val) { + if (val == null) return null; + const s = String(val).replace(/\./g, "").replace(",", ".").trim(); + if (!s) return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +} + +function parseBool(val) { + if (val == null) return null; + const s = String(val).trim().toLowerCase(); + if (s === "1" || s === "si" || s === "sí" || s === "yes" || s === "true") return true; + if (s === "0" || s === "no" || s === "false") return false; + return null; +} + +function splitList(val) { + if (!val) return []; + return String(val) + .split(",") + .map((v) => v.trim()) + .filter(Boolean); +} + +function extractAttributes(row) { + const attrs = {}; + for (const key of Object.keys(row)) { + const m = /^Nombre del atributo (\d+)$/i.exec(key); + if (!m) continue; + const idx = m[1]; + const name = String(row[key] || "").trim(); + if (!name) continue; + const valuesKey = `Valor(es) del atributo ${idx}`; + const rawValues = row[valuesKey]; + const values = String(rawValues || "") + .split("|") + .map((v) => v.trim()) + .filter(Boolean); + attrs[name.toLowerCase()] = values.length ? values : [String(rawValues || "").trim()].filter(Boolean); + } + return attrs; +} + +function normalizeRow(row) { + const wooId = Number(row["ID"] || row["Id"] || row["id"] || null); + const type = String(row["Tipo"] || "").trim().toLowerCase(); + const parentId = Number(row["Superior"] || null) || null; + const name = String(row["Nombre"] || "").trim(); + const slug = String(row["Slug"] || row["slug"] || "").trim() || null; + const published = parseBool(row["Publicado"]); + const status = published === true ? "publish" : published === false ? "draft" : null; + const visibility = String(row["Visibilidad en el catálogo"] || "").trim() || null; + const priceRegular = parseNumber(row["Precio normal"]); + const priceSale = parseNumber(row["Precio rebajado"]); + const priceCurrent = priceSale != null ? priceSale : priceRegular; + const hasStock = parseBool(row["¿Existencias?"]); + const stockQty = parseNumber(row["Inventario"]); + const backorders = String(row["¿Permitir reservas de productos agotados?"] || "").trim().toLowerCase() || null; + const weightKg = parseNumber(row["Peso (kg)"]); + const categories = splitList(row["Categorías"]); + const tags = splitList(row["Etiquetas"]); + const attributes = extractAttributes(row); + if (weightKg != null) attributes["peso_kg"] = [String(weightKg)]; + + let stockStatus = null; + if (hasStock === true) { + if (stockQty == null || stockQty > 0) stockStatus = "instock"; + else stockStatus = "outofstock"; + } else if (hasStock === false) { + stockStatus = "outofstock"; + } + + return { + woo_id: wooId, + type, + parent_id: parentId, + name, + slug, + status, + catalog_visibility: visibility, + price_regular: priceRegular, + price_sale: priceSale, + price_current: priceCurrent, + stock_status: stockStatus, + stock_qty: stockQty == null ? null : Math.round(stockQty), + backorders, + categories, + tags, + attributes_normalized: attributes, + }; +} + +async function getTenants(tenantKey) { + if (tenantKey) { + const { rows } = await pool.query(`select id, key from tenants where key=$1`, [tenantKey]); + return rows; + } + const { rows } = await pool.query(`select id, key from tenants`); + return rows; +} + +async function insertRun({ tenantId, total, source }) { + const { rows } = await pool.query( + `insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`, + [tenantId, source, total] + ); + return rows[0]?.id || null; +} + +async function upsertSnapshotItem({ tenantId, runId, item, raw }) { + const q = ` + insert into woo_products_snapshot + (tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility, + price_regular, price_sale, price_current, stock_status, stock_qty, backorders, + categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at) + values + ($1,$2,$3,$4,$5,$6,$7,$8, + $9,$10,$11,$12,$13,$14, + $15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now()) + on conflict (tenant_id, woo_id) + do update set + type = excluded.type, + parent_id = excluded.parent_id, + name = excluded.name, + slug = excluded.slug, + status = excluded.status, + catalog_visibility = excluded.catalog_visibility, + price_regular = excluded.price_regular, + price_sale = excluded.price_sale, + price_current = excluded.price_current, + stock_status = excluded.stock_status, + stock_qty = excluded.stock_qty, + backorders = excluded.backorders, + categories = excluded.categories, + tags = excluded.tags, + attributes_normalized = excluded.attributes_normalized, + date_modified = excluded.date_modified, + run_id = excluded.run_id, + raw = excluded.raw, + updated_at = now() + `; + await pool.query(q, [ + tenantId, + item.woo_id, + item.type, + item.parent_id, + item.name, + item.slug, + item.status, + item.catalog_visibility, + item.price_regular, + item.price_sale, + item.price_current, + item.stock_status, + item.stock_qty, + item.backorders, + JSON.stringify(item.categories ?? []), + JSON.stringify(item.tags ?? []), + JSON.stringify(item.attributes_normalized ?? {}), + null, + runId, + JSON.stringify(raw ?? {}), + ]); +} + +async function deleteMissing({ tenantId, runId }) { + await pool.query( + `delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`, + [tenantId, runId] + ); +} + +async function main() { + const { file, tenantKey, replace } = parseArgs(); + const abs = path.resolve(file); + const content = fs.readFileSync(abs); + const records = parse(content, { columns: true, skip_empty_lines: true }); + const normalized = records.map((r) => ({ item: normalizeRow(r), raw: r })).filter((r) => r.item.woo_id && r.item.name); + + const tenants = await getTenants(tenantKey); + if (!tenants.length) { + throw new Error("No tenants found for import"); + } + + for (const t of tenants) { + const runId = await insertRun({ tenantId: t.id, total: normalized.length, source: "csv" }); + for (const row of normalized) { + await upsertSnapshotItem({ tenantId: t.id, runId, item: row.item, raw: row.raw }); + } + if (replace && runId) { + await deleteMissing({ tenantId: t.id, runId }); + } + console.log(`[import] tenant=${t.key} items=${normalized.length} run_id=${runId}`); + } + + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app.js b/src/app.js index 5e4538e..a63a85b 100644 --- a/src/app.js +++ b/src/app.js @@ -3,15 +3,16 @@ import cors from "cors"; import path from "path"; import { fileURLToPath } from "url"; -import { createSimulatorRouter } from "./routes/simulator.js"; -import { createEvolutionRouter } from "./routes/evolution.js"; -import { createMercadoPagoRouter } from "./routes/mercadoPago.js"; +import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js"; +import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js"; +import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js"; +import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js"; export function createApp({ tenantId }) { const app = express(); app.use(cors()); - app.use(express.json({ limit: "1mb" })); + app.use(express.json({ limit: "1mb" })); // Serve /public as static (UI + webcomponents) const __filename = fileURLToPath(import.meta.url); @@ -23,6 +24,7 @@ export function createApp({ tenantId }) { app.use(createSimulatorRouter({ tenantId })); app.use(createEvolutionRouter()); app.use("/payments/meli", createMercadoPagoRouter()); + app.use(createWooWebhooksRouter()); // Home (UI) app.get("/", (req, res) => { diff --git a/src/handlers/admin.js b/src/handlers/admin.js index 2a68752..c2edc05 100644 --- a/src/handlers/admin.js +++ b/src/handlers/admin.js @@ -6,9 +6,9 @@ import { getIdentityMapByChat, getLastInboundMessage, listUsers, -} from "../db/repo.js"; -import { deleteWooCustomer } from "../services/woo.js"; -import { processMessage } from "../services/pipeline.js"; +} from "../modules/2-identity/db/repo.js"; +import { deleteWooCustomer } from "../modules/2-identity/services/woo.js"; +import { processMessage } from "../modules/2-identity/services/pipeline.js"; export async function handleDeleteConversation({ tenantId, chat_id }) { if (!chat_id) return { ok: false, error: "chat_id_required" }; diff --git a/src/handlers/conversationState.js b/src/handlers/conversationState.js index 39e05eb..801b8c4 100644 --- a/src/handlers/conversationState.js +++ b/src/handlers/conversationState.js @@ -1,4 +1,4 @@ -import { getConversationState } from "../db/repo.js"; +import { getConversationState } from "../modules/2-identity/db/repo.js"; export async function handleGetConversationState({ tenantId, chat_id }) { if (!chat_id) { diff --git a/src/handlers/conversations.js b/src/handlers/conversations.js index 29c5b42..6ab6816 100644 --- a/src/handlers/conversations.js +++ b/src/handlers/conversations.js @@ -1,4 +1,4 @@ -import { listConversations } from "../db/repo.js"; +import { listConversations } from "../modules/2-identity/db/repo.js"; export async function handleListConversations({ tenantId, query }) { const { q = "", status = "", state = "", limit = "50" } = query || {}; diff --git a/src/handlers/messages.js b/src/handlers/messages.js index 12e2541..d28453a 100644 --- a/src/handlers/messages.js +++ b/src/handlers/messages.js @@ -1,4 +1,4 @@ -import { listMessages } from "../db/repo.js"; +import { listMessages } from "../modules/2-identity/db/repo.js"; export async function handleListMessages({ tenantId, chat_id, limit = "200" }) { if (!chat_id) return []; diff --git a/src/handlers/products.js b/src/handlers/products.js index 4c9938a..fccb449 100644 --- a/src/handlers/products.js +++ b/src/handlers/products.js @@ -1,11 +1,10 @@ -import { searchProducts } from "../services/wooProducts.js"; +import { searchSnapshotItems } from "../modules/shared/wooSnapshot.js"; export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) { - const { items, source } = await searchProducts({ + const { items, source } = await searchSnapshotItems({ tenantId, q, limit: parseInt(limit, 10) || 10, - forceWoo: String(forceWoo) === "1" || String(forceWoo).toLowerCase() === "true", }); return { items, source }; } diff --git a/src/handlers/runs.js b/src/handlers/runs.js index e29e675..f1a616a 100644 --- a/src/handlers/runs.js +++ b/src/handlers/runs.js @@ -1,4 +1,4 @@ -import { listRuns, getRunById } from "../db/repo.js"; +import { listRuns, getRunById } from "../modules/2-identity/db/repo.js"; export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) { return listRuns({ diff --git a/src/controllers/evolution.js b/src/modules/1-intake/controllers/evolution.js similarity index 100% rename from src/controllers/evolution.js rename to src/modules/1-intake/controllers/evolution.js diff --git a/src/controllers/sim.js b/src/modules/1-intake/controllers/sim.js similarity index 100% rename from src/controllers/sim.js rename to src/modules/1-intake/controllers/sim.js diff --git a/src/handlers/evolution.js b/src/modules/1-intake/handlers/evolution.js similarity index 90% rename from src/handlers/evolution.js rename to src/modules/1-intake/handlers/evolution.js index 661bd47..034f8ad 100644 --- a/src/handlers/evolution.js +++ b/src/modules/1-intake/handlers/evolution.js @@ -1,7 +1,7 @@ import crypto from "crypto"; import { parseEvolutionWebhook } from "../services/evolutionParser.js"; -import { resolveTenantId, processMessage } from "../services/pipeline.js"; -import { debug as dbg } from "../services/debug.js"; +import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js"; +import { debug as dbg } from "../../shared/debug.js"; export async function handleEvolutionWebhook(body) { const t0 = Date.now(); diff --git a/src/handlers/sim.js b/src/modules/1-intake/handlers/sim.js similarity index 82% rename from src/handlers/sim.js rename to src/modules/1-intake/handlers/sim.js index 2180edb..987ad46 100644 --- a/src/handlers/sim.js +++ b/src/modules/1-intake/handlers/sim.js @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { resolveTenantId } from "../services/pipeline.js"; -import { processMessage } from "../services/pipeline.js"; +import { resolveTenantId } from "../../2-identity/services/pipeline.js"; +import { processMessage } from "../../2-identity/services/pipeline.js"; export async function handleSimSend(body) { const { chat_id, from_phone, text } = body || {}; diff --git a/src/routes/evolution.js b/src/modules/1-intake/routes/evolution.js similarity index 100% rename from src/routes/evolution.js rename to src/modules/1-intake/routes/evolution.js diff --git a/src/routes/simulator.js b/src/modules/1-intake/routes/simulator.js similarity index 77% rename from src/routes/simulator.js rename to src/modules/1-intake/routes/simulator.js index 29d9379..16e6401 100644 --- a/src/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -1,13 +1,13 @@ import express from "express"; -import { addSseClient, removeSseClient } from "../services/sse.js"; -import { makeGetConversations } from "../controllers/conversations.js"; -import { makeListRuns, makeGetRunById } from "../controllers/runs.js"; +import { addSseClient, removeSseClient } from "../../shared/sse.js"; +import { makeGetConversations } from "../../../controllers/conversations.js"; +import { makeListRuns, makeGetRunById } from "../../../controllers/runs.js"; import { makeSimSend } from "../controllers/sim.js"; -import { makeGetConversationState } from "../controllers/conversationState.js"; -import { makeListMessages } from "../controllers/messages.js"; -import { makeSearchProducts } from "../controllers/products.js"; -import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../controllers/admin.js"; +import { makeGetConversationState } from "../../../controllers/conversationState.js"; +import { makeListMessages } from "../../../controllers/messages.js"; +import { makeSearchProducts } from "../../../controllers/products.js"; +import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../../controllers/admin.js"; function nowIso() { return new Date().toISOString(); diff --git a/src/services/evolutionParser.js b/src/modules/1-intake/services/evolutionParser.js similarity index 99% rename from src/services/evolutionParser.js rename to src/modules/1-intake/services/evolutionParser.js index d24c017..165338a 100644 --- a/src/services/evolutionParser.js +++ b/src/modules/1-intake/services/evolutionParser.js @@ -55,3 +55,4 @@ export function parseEvolutionWebhook(reqBody) { raw: body, // para log/debug si querés }; } + diff --git a/src/modules/2-identity/controllers/wooWebhooks.js b/src/modules/2-identity/controllers/wooWebhooks.js new file mode 100644 index 0000000..4bfb565 --- /dev/null +++ b/src/modules/2-identity/controllers/wooWebhooks.js @@ -0,0 +1,59 @@ +import { refreshProductByWooId } from "../../shared/wooSnapshot.js"; +import { getTenantByKey } from "../db/repo.js"; + +function unauthorized(res) { + res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"'); + return res.status(401).json({ ok: false, error: "unauthorized" }); +} + +function checkBasicAuth(req) { + const user = process.env.WOO_WEBHOOK_USER || ""; + const pass = process.env.WOO_WEBHOOK_PASS || ""; + const auth = req.headers?.authorization || ""; + if (!user || !pass) return { ok: false, reason: "missing_env" }; + if (!auth.startsWith("Basic ")) return { ok: false, reason: "missing_basic" }; + const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8"); + const [u, p] = decoded.split(":"); + if (u === user && p === pass) return { ok: true }; + return { ok: false, reason: "invalid_creds" }; +} + +function parseWooId(payload) { + const id = payload?.id || payload?.data?.id || null; + const parentId = payload?.parent_id || payload?.data?.parent_id || null; + const resource = payload?.resource || payload?.topic || null; + return { id: id ? Number(id) : null, parentId: parentId ? Number(parentId) : null, resource }; +} + +export function makeWooProductWebhook() { + return async function handleWooProductWebhook(req, res) { + const auth = checkBasicAuth(req); + if (!auth.ok) return unauthorized(res); + + const { id, parentId, resource } = parseWooId(req.body || {}); + if (!id) return res.status(400).json({ ok: false, error: "missing_id" }); + + // Determinar tenant por query ?tenant_key=... + const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null; + if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" }); + const tenant = await getTenantByKey(String(tenantKey).toLowerCase()); + if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" }); + + const parentForVariation = + resource && String(resource).includes("variation") ? parentId || null : null; + + const updated = await refreshProductByWooId({ + tenantId: tenant.id, + wooId: id, + parentId: parentForVariation, + }); + + return res.status(200).json({ + ok: true, + woo_id: updated?.woo_id || id, + type: updated?.type || null, + parent_id: updated?.parent_id || null, + }); + }; +} + diff --git a/src/db/pool.js b/src/modules/2-identity/db/pool.js similarity index 100% rename from src/db/pool.js rename to src/modules/2-identity/db/pool.js diff --git a/src/db/repo.js b/src/modules/2-identity/db/repo.js similarity index 87% rename from src/db/repo.js rename to src/modules/2-identity/db/repo.js index 68f0dac..8a7e310 100644 --- a/src/db/repo.js +++ b/src/modules/2-identity/db/repo.js @@ -288,7 +288,7 @@ export async function getRecentMessagesForLLM({ `; const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]); - return rows.reverse().map(r => ({ + return rows.reverse().map((r) => ({ role: r.direction === "in" ? "user" : "assistant", content: String(r.text).trim().slice(0, maxCharsPerMessage), })); @@ -529,98 +529,6 @@ export async function getDecryptedTenantEcommerceConfig({ return rows[0] || null; } -export async function upsertWooProductCache({ - tenant_id, - woo_product_id, - name, - sku = null, - price = null, - currency = null, - payload = {}, - refreshed_at = null, -}) { - const q = ` - insert into woo_products_cache - (tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, created_at, updated_at) - values - ($1, $2, $3, $4, $5, $6, coalesce($7::timestamptz, now()), $8::jsonb, now(), now()) - on conflict (tenant_id, woo_product_id) - do update set - name = excluded.name, - sku = excluded.sku, - price = excluded.price, - currency = excluded.currency, - refreshed_at = excluded.refreshed_at, - payload = excluded.payload, - updated_at = now() - returning tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at - `; - - const { rows } = await pool.query(q, [ - tenant_id, - woo_product_id, - name, - sku, - price, - currency, - refreshed_at, - JSON.stringify(payload ?? {}), - ]); - return rows[0] || null; -} - -export async function searchWooProductCache({ tenant_id, q = "", limit = 20 }) { - const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20)); - const query = String(q || "").trim(); - if (!query) return []; - - // Búsqueda simple: name o sku (ilike). Más adelante: trigram/FTS si hace falta. - const like = `%${query}%`; - const sql = ` - select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at - from woo_products_cache - where tenant_id=$1 - and (name ilike $2 or coalesce(sku,'') ilike $2) - order by refreshed_at desc - limit $3 - `; - const { rows } = await pool.query(sql, [tenant_id, like, lim]); - return rows.map((r) => ({ - tenant_id: r.tenant_id, - woo_product_id: r.woo_product_id, - name: r.name, - sku: r.sku, - price: r.price, - currency: r.currency, - refreshed_at: r.refreshed_at, - payload: r.payload, - updated_at: r.updated_at, - })); -} - -export async function getWooProductCacheById({ tenant_id, woo_product_id }) { - const sql = ` - select tenant_id, woo_product_id, name, sku, price, currency, refreshed_at, payload, updated_at - from woo_products_cache - where tenant_id=$1 and woo_product_id=$2 - limit 1 - `; - const { rows } = await pool.query(sql, [tenant_id, woo_product_id]); - const r = rows[0]; - if (!r) return null; - return { - tenant_id: r.tenant_id, - woo_product_id: r.woo_product_id, - name: r.name, - sku: r.sku, - price: r.price, - currency: r.currency, - refreshed_at: r.refreshed_at, - payload: r.payload, - updated_at: r.updated_at, - }; -} - export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) { const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20)); const query = String(q || "").trim(); @@ -736,4 +644,5 @@ export async function getMpPaymentById({ tenant_id, payment_id }) { `; const { rows } = await pool.query(sql, [tenant_id, payment_id]); return rows[0] || null; -} \ No newline at end of file +} + diff --git a/src/modules/2-identity/routes/wooWebhooks.js b/src/modules/2-identity/routes/wooWebhooks.js new file mode 100644 index 0000000..a582f55 --- /dev/null +++ b/src/modules/2-identity/routes/wooWebhooks.js @@ -0,0 +1,9 @@ +import express from "express"; +import { makeWooProductWebhook } from "../controllers/wooWebhooks.js"; + +export function createWooWebhooksRouter() { + const router = express.Router(); + router.post("/webhook/woo/products", makeWooProductWebhook()); + return router; +} + diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js new file mode 100644 index 0000000..8f31997 --- /dev/null +++ b/src/modules/2-identity/services/pipeline.js @@ -0,0 +1,437 @@ +import crypto from "crypto"; +import { + getConversationState, + insertMessage, + insertRun, + touchConversationState, + upsertConversationState, + getRecentMessagesForLLM, + getExternalCustomerIdByChat, + upsertExternalCustomerMap, + updateRunLatency, + getTenantByKey, + getTenantIdByChannel, +} from "../db/repo.js"; +import { sseSend } from "../../shared/sse.js"; +import { createWooCustomer, getWooCustomerById } from "./woo.js"; +import { debug as dbg } from "../../shared/debug.js"; +import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js"; +import { safeNextState } from "../../3-turn-engine/fsm.js"; +import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js"; +import { createPreference } from "../../6-mercadopago/mercadoPago.js"; + +function nowIso() { + return new Date().toISOString(); +} + +function newId(prefix = "run") { + return `${prefix}_${crypto.randomUUID()}`; +} + +function makePerf() { + const started_at = Date.now(); + const perf = { t0: started_at, marks: {} }; + const mark = (name) => { + perf.marks[name] = Date.now(); + }; + const msBetween = (a, b) => { + const ta = a === "t0" ? perf.t0 : perf.marks[a]; + const tb = b === "t0" ? perf.t0 : perf.marks[b]; + if (!ta || !tb) return null; + return tb - ta; + }; + return { started_at, perf, mark, msBetween }; +} + +function logStage(enabled, stage, payload) { + if (!enabled) return; + console.log(`[pipeline] ${stage}`, payload); +} + +function collapseAssistantMessages(messages) { + const out = []; + for (const m of messages || []) { + const last = out[out.length - 1]; + if (last && last.role === "assistant" && m.role === "assistant") continue; + out.push(m); + } + return out; +} + +async function ensureWooCustomerId({ + tenantId, + chat_id, + displayName, + from, + externalCustomerId, + run_id, +}) { + let updatedId = externalCustomerId; + let error = null; + try { + if (updatedId) { + const found = await getWooCustomerById({ tenantId, id: updatedId }); + if (!found) { + const phone = chat_id.replace(/@.+$/, ""); + const name = displayName || from || phone; + const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); + if (!created?.id) throw new Error("woo_customer_id_missing"); + updatedId = await upsertExternalCustomerMap({ + tenant_id: tenantId, + wa_chat_id: chat_id, + external_customer_id: created?.id, + provider: "woo", + }); + } else { + updatedId = await upsertExternalCustomerMap({ + tenant_id: tenantId, + wa_chat_id: chat_id, + external_customer_id: updatedId, + provider: "woo", + }); + } + } else { + const phone = chat_id.replace(/@.+$/, ""); + const name = displayName || from || phone; + const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); + if (!created?.id) throw new Error("woo_customer_id_missing"); + updatedId = await upsertExternalCustomerMap({ + tenant_id: tenantId, + wa_chat_id: chat_id, + external_customer_id: created?.id, + provider: "woo", + }); + } + } catch (e) { + error = { + message: String(e?.message || e), + status: e?.status || e?.cause?.status || null, + code: e?.body?.code || e?.cause?.body?.code || null, + run_id: run_id || null, + }; + } + return { external_customer_id: updatedId, error }; +} + +export async function processMessage({ + tenantId, + chat_id, + from, + text, + provider, + message_id, + displayName = null, + meta = null, +}) { + const { started_at, mark, msBetween } = makePerf(); + + await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id }); + + mark("start"); + const stageDebug = dbg.perf; + const prev = await getConversationState(tenantId, chat_id); + mark("after_getConversationState"); + const isStale = + prev?.state_updated_at && + Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; + const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; + let externalCustomerId = await getExternalCustomerIdByChat({ + tenant_id: tenantId, + wa_chat_id: chat_id, + provider: "woo", + }); + mark("after_getExternalCustomerIdByChat"); + + await insertMessage({ + tenant_id: tenantId, + wa_chat_id: chat_id, + provider, + message_id, + direction: "in", + text, + payload: { raw: { from, text, meta } }, + run_id: null, + }); + mark("after_insertMessage_in"); + + mark("before_getRecentMessagesForLLM_for_plan"); + const history = await getRecentMessagesForLLM({ + tenant_id: tenantId, + wa_chat_id: chat_id, + limit: 20, + }); + const conversation_history = collapseAssistantMessages(history); + mark("after_getRecentMessagesForLLM_for_plan"); + + logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state }); + + let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {}; + let decision; + let plan; + let llmMeta; + let tools = []; + + mark("before_turn_v3"); + const out = await runTurnV3({ + tenantId, + chat_id, + text, + prev_state, + prev_context: reducedContext, + conversation_history, + }); + plan = out.plan; + decision = out.decision || { context_patch: {}, actions: [], audit: {} }; + llmMeta = { kind: "nlu_v3", audit: decision.audit || null }; + tools = []; + mark("after_turn_v3"); + + const runStatus = llmMeta?.error ? "warn" : "ok"; + const isSimulated = provider === "sim" || meta?.source === "sim"; + + const invariants = { + ok: true, + checks: [ + { name: "required_keys_present", ok: true }, + { name: "no_checkout_without_payment_link", ok: true }, + { name: "no_order_action_without_items", ok: true }, + ], + }; + mark("before_insertRun"); + + // --- Ejecutar acciones determinísticas --- + let actionPatch = {}; + if (Array.isArray(decision?.actions) && decision.actions.length) { + const newTools = []; + const actions = decision.actions; + + const calcOrderTotal = (order) => { + const rawTotal = Number(order?.raw?.total); + if (Number.isFinite(rawTotal) && rawTotal > 0) return rawTotal; + const items = Array.isArray(order?.line_items) ? order.line_items : []; + let sum = 0; + for (const it of items) { + const t = Number(it?.total); + if (Number.isFinite(t)) sum += t; + } + return sum > 0 ? sum : null; + }; + + const needsWoo = actions.some((a) => a.type === "create_order" || a.type === "update_order"); + if (needsWoo) { + const ensured = await ensureWooCustomerId({ + tenantId, + chat_id, + displayName, + from, + externalCustomerId, + }); + externalCustomerId = ensured.external_customer_id; + if (ensured.error) { + newTools.push({ type: "ensure_woo_customer", ok: false, error: ensured.error }); + } else { + newTools.push({ type: "ensure_woo_customer", ok: true, external_customer_id: externalCustomerId }); + } + } + + for (const act of actions) { + try { + if (act.type === "create_order") { + const order = await createOrder({ + tenantId, + wooCustomerId: externalCustomerId, + basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] }, + address: reducedContext?.delivery_address || reducedContext?.address || null, + run_id: null, + }); + actionPatch.woo_order_id = order?.id || null; + actionPatch.order_total = calcOrderTotal(order); + newTools.push({ type: "create_order", ok: true, order_id: order?.id || null }); + } else if (act.type === "update_order") { + const order = await updateOrder({ + tenantId, + wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null, + basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] }, + address: reducedContext?.delivery_address || reducedContext?.address || null, + run_id: null, + }); + actionPatch.woo_order_id = order?.id || null; + actionPatch.order_total = calcOrderTotal(order); + newTools.push({ type: "update_order", ok: true, order_id: order?.id || null }); + } else if (act.type === "send_payment_link") { + const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null; + if (!total || total <= 0) { + throw new Error("order_total_missing"); + } + const pref = await createPreference({ + tenantId, + wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id, + amount: total || 0, + }); + actionPatch.payment_link = pref?.init_point || null; + actionPatch.mp = { + preference_id: pref?.preference_id || null, + init_point: pref?.init_point || null, + }; + newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null }); + if (pref?.init_point) { + plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`; + } + } + } catch (e) { + newTools.push({ type: act.type, ok: false, error: String(e?.message || e) }); + } + } + + tools = newTools; + } + + const run_id = await insertRun({ + tenant_id: tenantId, + wa_chat_id: chat_id, + message_id: `${provider}:${message_id}`, + prev_state, + user_text: text, + llm_output: { ...plan, _llm: llmMeta }, + tools, + invariants, + final_reply: plan.reply, + status: runStatus, + latency_ms: null, + }); + mark("after_insertRun"); + + const outMessageId = newId("out"); + await insertMessage({ + tenant_id: tenantId, + wa_chat_id: chat_id, + provider, + message_id: outMessageId, + direction: "out", + text: plan.reply, + payload: { reply: plan.reply, railguard: { simulated: isSimulated, source: meta?.source || null } }, + run_id, + }); + mark("after_insertMessage_out"); + + if (llmMeta?.error) { + const errMsgId = newId("err"); + await insertMessage({ + tenant_id: tenantId, + wa_chat_id: chat_id, + provider: "system", + message_id: errMsgId, + direction: "out", + text: `[ERROR] openai: ${llmMeta.error}`, + payload: { error: { source: "openai", ...llmMeta }, railguard: { simulated: isSimulated, source: meta?.source || null } }, + run_id, + }); + } + + let wooCustomerError = null; + if (tools.some((t) => t.type === "ensure_woo_customer" && !t.ok)) { + wooCustomerError = tools.find((t) => t.type === "ensure_woo_customer" && !t.ok)?.error || null; + } + + const context = { + ...(reducedContext || {}), + ...(decision?.context_patch || {}), + ...(actionPatch || {}), + missing_fields: plan.missing_fields || [], + basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] }, + external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, + railguard: { simulated: isSimulated, source: meta?.source || null }, + woo_customer_error: wooCustomerError, + }; + + const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state; + plan.next_state = nextState; + + const stateRow = await upsertConversationState({ + tenant_id: tenantId, + wa_chat_id: chat_id, + state: nextState, + last_intent: plan.intent, + last_order_id: null, + context, + }); + mark("after_upsertConversationState"); + + sseSend("conversation.upsert", { + chat_id: stateRow.wa_chat_id, + from: stateRow.wa_chat_id.replace(/^sim:/, ""), + state: stateRow.state, + intent: stateRow.last_intent || "other", + status: runStatus, + last_activity: stateRow.updated_at, + last_run_id: run_id, + }); + + const end_to_end_ms = Date.now() - started_at; + if (run_id) { + await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms }); + } + + sseSend("run.created", { + run_id, + ts: nowIso(), + chat_id, + from, + status: runStatus, + prev_state, + input: { text }, + llm_output: { ...plan, _llm: llmMeta }, + tools, + invariants, + final_reply: plan.reply, + order_id: actionPatch.woo_order_id || null, + payment_link: actionPatch.payment_link || null, + latency_ms: end_to_end_ms, + }); + + console.log("[perf] processMessage", { + tenantId, + chat_id, + provider, + message_id, + run_id, + end_to_end_ms, + ms: { + db_state_ms: msBetween("start", "after_getConversationState"), + db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"), + insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"), + history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"), + insert_run_ms: msBetween("before_insertRun", "after_insertRun"), + insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"), + upsert_state_ms: msBetween("after_insertMessage_out", "after_upsertConversationState"), + }, + }); + + return { run_id, reply: plan.reply }; +} + +function parseTenantFromChatId(chat_id) { + const m = /^([a-z0-9_-]+):/.exec(chat_id); + return m?.[1]?.toLowerCase() || null; +} + +export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) { + const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase(); + + if (explicit) { + const t = await getTenantByKey(explicit); + if (t) return t.id; + throw new Error(`tenant_not_found: ${explicit}`); + } + + if (to_phone) { + const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone }); + if (id) return id; + } + + const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase(); + const t = await getTenantByKey(fallbackKey); + if (t) return t.id; + throw new Error(`tenant_not_found: ${fallbackKey}`); +} + diff --git a/src/services/woo.js b/src/modules/2-identity/services/woo.js similarity index 98% rename from src/services/woo.js rename to src/modules/2-identity/services/woo.js index 20085d0..0bf3e2c 100644 --- a/src/services/woo.js +++ b/src/modules/2-identity/services/woo.js @@ -1,6 +1,6 @@ import crypto from "crypto"; import { getDecryptedTenantEcommerceConfig } from "../db/repo.js"; -import { debug } from "./debug.js"; +import { debug } from "../../shared/debug.js"; // --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) --- const locks = new Map(); @@ -42,12 +42,8 @@ function isRetryableNetworkError(err) { const e2 = getDeep(err, ["cause", "cause"]); const candidates = [e0, e1, e2].filter(Boolean); - const codes = new Set( - candidates.map((e) => e.code).filter(Boolean) - ); - const names = new Set( - candidates.map((e) => e.name).filter(Boolean) - ); + const codes = new Set(candidates.map((e) => e.code).filter(Boolean)); + const names = new Set(candidates.map((e) => e.name).filter(Boolean)); const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase(); const aborted = diff --git a/src/services/catalogRetrieval.js b/src/modules/3-turn-engine/catalogRetrieval.js similarity index 95% rename from src/services/catalogRetrieval.js rename to src/modules/3-turn-engine/catalogRetrieval.js index ca6b1b5..f024312 100644 --- a/src/services/catalogRetrieval.js +++ b/src/modules/3-turn-engine/catalogRetrieval.js @@ -1,12 +1,12 @@ import crypto from "crypto"; import OpenAI from "openai"; -import { debug as dbg } from "./debug.js"; -import { searchProducts } from "./wooProducts.js"; +import { debug as dbg } from "../shared/debug.js"; +import { searchSnapshotItems } from "../shared/wooSnapshot.js"; import { searchProductAliases, getProductEmbedding, upsertProductEmbedding, -} from "../db/repo.js"; +} from "../2-identity/db/repo.js"; function getOpenAiKey() { return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null; @@ -148,13 +148,12 @@ export async function retrieveCandidates({ } audit.sources.aliases = aliases.length; - const { items: wooItems, source: wooSource } = await searchProducts({ + const { items: wooItems, source: wooSource } = await searchSnapshotItems({ tenantId, q, limit: lim, - forceWoo: true, }); - audit.sources.woo = { source: wooSource, count: wooItems?.length || 0 }; + audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 }; let candidates = (wooItems || []).map((c) => { const lit = literalScore(q, c); @@ -208,3 +207,4 @@ export async function retrieveCandidates({ return { candidates: finalList, audit }; } + diff --git a/src/services/fsm.js b/src/modules/3-turn-engine/fsm.js similarity index 100% rename from src/services/fsm.js rename to src/modules/3-turn-engine/fsm.js diff --git a/src/services/openai.js b/src/modules/3-turn-engine/openai.js similarity index 51% rename from src/services/openai.js rename to src/modules/3-turn-engine/openai.js index 23bb563..0019fa9 100644 --- a/src/services/openai.js +++ b/src/modules/3-turn-engine/openai.js @@ -1,7 +1,6 @@ import OpenAI from "openai"; -import { z } from "zod"; import Ajv from "ajv"; -import { debug as dbg } from "./debug.js"; +import { debug as dbg } from "../shared/debug.js"; let _client = null; let _clientKey = null; @@ -23,67 +22,6 @@ function getClient() { return _client; } -const NextStateSchema = z.enum([ - "IDLE", - "BROWSING", - "BUILDING_ORDER", - "WAITING_ADDRESS", - "WAITING_PAYMENT", - "COMPLETED", -]); - -const IntentSchema = z.enum([ - "ask_recommendation", - "ask_price", - "browse_products", - "create_order", - "add_item", - "remove_item", - "checkout", - "provide_address", - "confirm_payment", - "track_order", - "other", -]); - -const OrderActionSchema = z.enum(["none", "create", "update", "cancel", "checkout"]); - -const BasketItemSchema = z.object({ - product_id: z.number().int().nonnegative(), - variation_id: z.number().int().nonnegative().nullable(), - quantity: z.number().positive(), - unit: z.enum(["kg", "g", "unit"]), - label: z.string().min(1), -}); - -const PlanSchema = z - .object({ - reply: z.string().min(1).max(350).catch(z.string().min(1)), // respetar guideline, sin romper si excede - next_state: NextStateSchema, - intent: IntentSchema, - missing_fields: z.array(z.string()).default([]), - order_action: OrderActionSchema.default("none"), - basket_resolved: z - .object({ - items: z.array(BasketItemSchema).default([]), - }) - .default({ items: [] }), - }) - .strict(); - -const ExtractItemSchema = z.object({ - label: z.string().min(1), - quantity: z.number().positive(), - unit: z.enum(["kg", "g", "unit"]), -}); - -const ExtractSchema = z - .object({ - intent: IntentSchema, - items: z.array(ExtractItemSchema).default([]), - }) - .strict(); - function extractJsonObject(text) { const s = String(text || ""); const i = s.indexOf("{"); @@ -262,138 +200,4 @@ export async function llmNluV3({ input, model } = {}) { } } -/** - * Genera un "plan" de conversación (salida estructurada) usando OpenAI. - * - * - `promptSystem`: instrucciones del bot - * - `input`: { last_user_message, conversation_history, current_conversation_state, context } - */ -export async function llmPlan({ promptSystem, input, model } = {}) { - const system = - `${promptSystem}\n\n` + - "Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido."; - const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({ - system, - user: JSON.stringify(input ?? {}), - model, - }); - - const plan = PlanSchema.parse(parsed); - return { - plan, - raw_text, - model: chosenModel, - usage, - }; -} - -/** - * Paso 1: extracción de intención + items mencionados (sin resolver IDs). - * Devuelve SOLO: intent + items[{label, quantity, unit}] - */ -export async function llmExtract({ input, model } = {}) { - const system = - "Extraé intención e items del mensaje del usuario.\n" + - "Respondé SOLO JSON válido (sin markdown) con keys EXACTAS:\n" + - `intent (one of: ${IntentSchema.options.join("|")}), items (array of {label, quantity, unit(kg|g|unit)}).\n` + - "Si no hay items claros, devolvé items: []."; - - const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({ - system, - user: JSON.stringify(input ?? {}), - model, - }); - - const extracted = ExtractSchema.parse(parsed); - return { extracted, raw_text, model: chosenModel, usage }; -} - -// --- NLU v2 (LLM-first) --- - -const NluIntentV2Schema = z.enum([ - "price_query", - "browse", - "add_to_cart", - "remove_from_cart", - "checkout", - "delivery_question", - "store_hours", - "greeting", - "other", -]); - -const NluSelectionSchema = z - .object({ - type: z.enum(["index", "text", "sku"]), - value: z.string().min(1), - }) - .nullable() - .default(null); - -const NluEntitiesSchema = z - .object({ - product_query: z.string().nullable().default(null), - quantity: z.number().nullable().default(null), - unit: z.enum(["kg", "g", "unidad", "docena"]).nullable().default(null), - selection: NluSelectionSchema, - attributes: z.array(z.string()).default([]), - preparation: z.array(z.string()).default([]), - budget: z.number().nullable().default(null), - }) - .strict(); - -const NluNeedsSchema = z - .object({ - catalog_lookup: z.boolean().default(false), - knowledge_lookup: z.boolean().default(false), - }) - .strict(); - -const NluClarificationSchema = z - .object({ - reason: z.enum([ - "ambiguous_product", - "missing_quantity", - "missing_variant", - "missing_delivery_zone", - "none", - ]), - question: z.string().nullable().default(null), - }) - .strict(); - -const NluV2Schema = z - .object({ - intent: NluIntentV2Schema, - confidence: z.number().min(0).max(1).default(0.5), - language: z.string().default("es-AR"), - entities: NluEntitiesSchema, - dialogue_act: z.enum(["answer", "ask_clarification", "confirm", "propose_options"]).default("answer"), - needs: NluNeedsSchema, - clarification: NluClarificationSchema, - }) - .strict(); - -export async function llmNlu({ input, model } = {}) { - const system = - "Sos un servicio NLU para un asistente de carnicería en Argentina (es-AR).\n" + - "Tu tarea es EXTRAER intención, entidades y acto conversacional del mensaje del usuario.\n" + - "Respondé SOLO JSON válido (sin markdown) y con keys EXACTAS según el contrato.\n" + - "\n" + - "Reglas críticas:\n" + - "- Si el contexto incluye last_shown_options y el usuario responde con un número o 'el segundo/la cuarta', eso es selection {type:'index'}.\n" + - "- Si el usuario pone '2kg' o '500g' o '3 unidades' eso es quantity+unit.\n" + - "- Si el usuario pone solo un número y hay opciones mostradas, interpretalo como selection (no como cantidad).\n" + - "- Si el contexto indica pending_item (ya hay producto elegido) y NO hay opciones mostradas, y el usuario pone solo un número, interpretalo como quantity (con unit null o la que indique el usuario).\n" + - "- No inventes productos ni SKUs. product_query es lo que el usuario pidió (ej 'asado', 'tapa de asado wagyu').\n" + - "- needs.catalog_lookup debe ser true para intents: price_query, browse, add_to_cart (salvo que sea pura selección numérica sobre opciones ya mostradas).\n"; - - const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({ - system, - user: JSON.stringify(input ?? {}), - model, - }); - - const nlu = NluV2Schema.parse(parsed); - return { nlu, raw_text, model: chosenModel, usage }; -} \ No newline at end of file +// Legacy llmPlan/llmExtract y NLU v2 removidos. diff --git a/src/services/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js similarity index 99% rename from src/services/turnEngineV3.js rename to src/modules/3-turn-engine/turnEngineV3.js index 3b6cbb1..e09fec3 100644 --- a/src/services/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -582,3 +582,4 @@ export async function runTurnV3({ decision: { actions, context_patch, audit: { ...audit, fsm: v } }, }; } + diff --git a/src/services/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js similarity index 96% rename from src/services/wooOrders.js rename to src/modules/4-woo-orders/wooOrders.js index 849c981..9aba889 100644 --- a/src/services/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -1,5 +1,6 @@ -import { getDecryptedTenantEcommerceConfig, getWooProductCacheById } from "../db/repo.js"; -import { debug as dbg } from "./debug.js"; +import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; +import { debug as dbg } from "../shared/debug.js"; +import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js"; // --- Simple in-memory lock to serialize work per key --- const locks = new Map(); @@ -100,8 +101,8 @@ function parsePrice(p) { async function getWooProductPrice({ tenantId, productId }) { if (!productId) return null; - const cached = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: productId }); - if (cached?.price != null) return Number(cached.price); + const snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId }); + if (snap != null) return Number(snap); const client = await getWooClient({ tenantId }); const url = `${client.base}/products/${encodeURIComponent(productId)}`; const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); @@ -234,3 +235,4 @@ export async function updateOrderStatus({ tenantId, wooOrderId, status }) { return { id: data?.id || wooOrderId, raw: data }; }); } + diff --git a/src/controllers/mercadoPago.js b/src/modules/6-mercadopago/controllers/mercadoPago.js similarity index 97% rename from src/controllers/mercadoPago.js rename to src/modules/6-mercadopago/controllers/mercadoPago.js index 40e19b9..eb2ba23 100644 --- a/src/controllers/mercadoPago.js +++ b/src/modules/6-mercadopago/controllers/mercadoPago.js @@ -1,4 +1,4 @@ -import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../services/mercadoPago.js"; +import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js"; export function makeMercadoPagoWebhook() { return async function handleMercadoPagoWebhook(req, res) { @@ -39,3 +39,4 @@ export function makeMercadoPagoReturn() { res.status(200).send(`OK - ${status}`); }; } + diff --git a/src/services/mercadoPago.js b/src/modules/6-mercadopago/mercadoPago.js similarity index 97% rename from src/services/mercadoPago.js rename to src/modules/6-mercadopago/mercadoPago.js index 704181c..d90028c 100644 --- a/src/services/mercadoPago.js +++ b/src/modules/6-mercadopago/mercadoPago.js @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { upsertMpPayment } from "../db/repo.js"; -import { updateOrderStatus } from "./wooOrders.js"; +import { upsertMpPayment } from "../2-identity/db/repo.js"; +import { updateOrderStatus } from "../4-woo-orders/wooOrders.js"; function getAccessToken() { return process.env.MP_ACCESS_TOKEN || null; @@ -175,3 +175,4 @@ export async function reconcilePayment({ tenantId, payment }) { return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId }; } + diff --git a/src/routes/mercadoPago.js b/src/modules/6-mercadopago/routes/mercadoPago.js similarity index 99% rename from src/routes/mercadoPago.js rename to src/modules/6-mercadopago/routes/mercadoPago.js index b73e7d6..ad1ebe0 100644 --- a/src/routes/mercadoPago.js +++ b/src/modules/6-mercadopago/routes/mercadoPago.js @@ -7,3 +7,4 @@ export function createMercadoPagoRouter() { router.get("/return", makeMercadoPagoReturn()); return router; } + diff --git a/src/services/debug.js b/src/modules/shared/debug.js similarity index 89% rename from src/services/debug.js rename to src/modules/shared/debug.js index 8144155..507aa46 100644 --- a/src/services/debug.js +++ b/src/modules/shared/debug.js @@ -13,7 +13,6 @@ function envIsOff(v) { * * - DEBUG_PERF: performance/latencias * - DEBUG_WOO_HTTP: requests/responses a Woo (status, timings, tamaño) - * - DEBUG_WOO_PRODUCTS: caching/queries de productos Woo * - DEBUG_LLM: requests/responses a OpenAI * - DEBUG_EVOLUTION: hook evolution + parse * - DEBUG_DB: queries/latencias DB (si se instrumenta) @@ -23,9 +22,6 @@ export const debug = { perf: envIsOn(process.env.DEBUG_PERF), wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP), - - wooProducts: envIsOn(process.env.DEBUG_WOO_PRODUCTS), - llm: envIsOn(process.env.DEBUG_LLM), evolution: envIsOn(process.env.DEBUG_EVOLUTION), @@ -39,4 +35,3 @@ export function debugOn(flagName) { return Boolean(debug?.[flagName]); } - diff --git a/src/services/sse.js b/src/modules/shared/sse.js similarity index 100% rename from src/services/sse.js rename to src/modules/shared/sse.js diff --git a/src/modules/shared/wooSnapshot.js b/src/modules/shared/wooSnapshot.js new file mode 100644 index 0000000..9d47187 --- /dev/null +++ b/src/modules/shared/wooSnapshot.js @@ -0,0 +1,253 @@ +import { pool } from "../2-identity/db/pool.js"; +import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; +import { debug as dbg } from "./debug.js"; + +async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout); + try { + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + const text = await res.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + if (!res.ok) { + const err = new Error(`Woo HTTP ${res.status}`); + err.status = res.status; + err.body = parsed; + err.url = url; + err.method = method; + throw err; + } + return parsed; + } finally { + clearTimeout(timer); + } +} + +async function getWooClient({ tenantId }) { + const encryptionKey = process.env.APP_ENCRYPTION_KEY; + if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials"); + const cfg = await getDecryptedTenantEcommerceConfig({ + tenant_id: tenantId, + provider: "woo", + encryption_key: encryptionKey, + }); + if (!cfg) throw new Error("Woo config not found for tenant"); + const consumerKey = + cfg.consumer_key || + process.env.WOO_CONSUMER_KEY || + (() => { + throw new Error("consumer_key not set"); + })(); + const consumerSecret = + cfg.consumer_secret || + process.env.WOO_CONSUMER_SECRET || + (() => { + throw new Error("consumer_secret not set"); + })(); + const base = cfg.base_url.replace(/\/+$/, ""); + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64"); + return { + base, + authHeader: { Authorization: `Basic ${auth}` }, + timeout: Math.max(cfg.timeout_ms ?? 20000, 20000), + }; +} + +function parsePrice(p) { + if (p == null) return null; + const n = Number(String(p).replace(",", ".")); + return Number.isFinite(n) ? n : null; +} + +function normalizeAttributes(attrs) { + const out = {}; + if (!Array.isArray(attrs)) return out; + for (const a of attrs) { + const name = String(a?.name || "").trim().toLowerCase(); + if (!name) continue; + const options = Array.isArray(a?.options) ? a.options.map((v) => String(v).trim()).filter(Boolean) : []; + const value = a?.option ? [String(a.option).trim()] : options; + if (value.length) out[name] = value; + } + return out; +} + +function normalizeWooProduct(p) { + return { + woo_id: p?.id, + type: p?.type || "simple", + parent_id: p?.parent_id || null, + name: p?.name || "", + slug: p?.slug || null, + status: p?.status || null, + catalog_visibility: p?.catalog_visibility || null, + price_regular: parsePrice(p?.regular_price), + price_sale: parsePrice(p?.sale_price), + price_current: parsePrice(p?.price), + stock_status: p?.stock_status || null, + stock_qty: p?.stock_quantity != null ? Number(p.stock_quantity) : null, + backorders: p?.backorders || null, + categories: Array.isArray(p?.categories) ? p.categories.map((c) => c?.name || c?.slug).filter(Boolean) : [], + tags: Array.isArray(p?.tags) ? p.tags.map((c) => c?.name || c?.slug).filter(Boolean) : [], + attributes_normalized: normalizeAttributes(p?.attributes || []), + date_modified: p?.date_modified || null, + raw: p, + }; +} + +function snapshotRowToItem(row) { + const categories = Array.isArray(row?.categories) ? row.categories : []; + const attributes = row?.attributes_normalized && typeof row.attributes_normalized === "object" ? row.attributes_normalized : {}; + return { + woo_product_id: row?.woo_id, + name: row?.name || "", + sku: row?.slug || null, + price: row?.price_current != null ? Number(row.price_current) : null, + currency: null, + type: row?.type || null, + categories: categories.map((c) => ({ id: null, name: String(c), slug: String(c) })), + attributes: Object.entries(attributes).map(([name, options]) => ({ + name, + options: Array.isArray(options) ? options : [String(options)], + })), + raw_price: { + price: row?.price_current ?? null, + regular_price: row?.price_regular ?? null, + sale_price: row?.price_sale ?? null, + price_html: null, + }, + source: "snapshot", + }; +} + +export async function insertSnapshotRun({ tenantId, source, total }) { + const { rows } = await pool.query( + `insert into woo_snapshot_runs (tenant_id, source, total_items) values ($1, $2, $3) returning id`, + [tenantId, source, total || 0] + ); + return rows[0]?.id || null; +} + +export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) { + const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 12)); + const query = String(q || "").trim(); + if (!query) return { items: [], source: "snapshot" }; + const like = `%${query}%`; + const sql = ` + select * + from sellable_items + where tenant_id=$1 + and (name ilike $2 or coalesce(slug,'') ilike $2) + order by updated_at desc + limit $3 + `; + const { rows } = await pool.query(sql, [tenantId, like, lim]); + return { items: rows.map(snapshotRowToItem), source: "snapshot" }; +} + +export async function getSnapshotPriceByWooId({ tenantId, wooId }) { + if (!wooId) return null; + const sql = ` + select price_current + from woo_products_snapshot + where tenant_id=$1 and woo_id=$2 + limit 1 + `; + const { rows } = await pool.query(sql, [tenantId, wooId]); + const price = rows[0]?.price_current; + return price == null ? null : Number(price); +} + +export async function upsertSnapshotItems({ tenantId, items, runId = null }) { + const rows = Array.isArray(items) ? items : []; + for (const item of rows) { + const q = ` + insert into woo_products_snapshot + (tenant_id, woo_id, type, parent_id, name, slug, status, catalog_visibility, + price_regular, price_sale, price_current, stock_status, stock_qty, backorders, + categories, tags, attributes_normalized, date_modified, run_id, raw, updated_at) + values + ($1,$2,$3,$4,$5,$6,$7,$8, + $9,$10,$11,$12,$13,$14, + $15::jsonb,$16::jsonb,$17::jsonb,$18,$19,$20::jsonb,now()) + on conflict (tenant_id, woo_id) + do update set + type = excluded.type, + parent_id = excluded.parent_id, + name = excluded.name, + slug = excluded.slug, + status = excluded.status, + catalog_visibility = excluded.catalog_visibility, + price_regular = excluded.price_regular, + price_sale = excluded.price_sale, + price_current = excluded.price_current, + stock_status = excluded.stock_status, + stock_qty = excluded.stock_qty, + backorders = excluded.backorders, + categories = excluded.categories, + tags = excluded.tags, + attributes_normalized = excluded.attributes_normalized, + date_modified = excluded.date_modified, + run_id = excluded.run_id, + raw = excluded.raw, + updated_at = now() + `; + await pool.query(q, [ + tenantId, + item.woo_id, + item.type, + item.parent_id, + item.name, + item.slug, + item.status, + item.catalog_visibility, + item.price_regular, + item.price_sale, + item.price_current, + item.stock_status, + item.stock_qty, + item.backorders, + JSON.stringify(item.categories || []), + JSON.stringify(item.tags || []), + JSON.stringify(item.attributes_normalized || {}), + item.date_modified, + runId, + JSON.stringify(item.raw || {}), + ]); + } +} + +export async function deleteMissingItems({ tenantId, runId }) { + if (!runId) return; + await pool.query( + `delete from woo_products_snapshot where tenant_id=$1 and coalesce(run_id,0) <> $2`, + [tenantId, runId] + ); +} + +export async function refreshProductByWooId({ tenantId, wooId, parentId = null }) { + const client = await getWooClient({ tenantId }); + let url = `${client.base}/products/${encodeURIComponent(wooId)}`; + if (parentId) { + url = `${client.base}/products/${encodeURIComponent(parentId)}/variations/${encodeURIComponent(wooId)}`; + } + const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); + if (dbg.wooHttp) console.log("[wooSnapshot] refresh", { wooId, parentId, type: data?.type }); + const normalized = normalizeWooProduct(data); + await upsertSnapshotItems({ tenantId, items: [normalized], runId: null }); + return normalized; +} + diff --git a/src/services/pipeline.js b/src/services/pipeline.js deleted file mode 100644 index 238cb4b..0000000 --- a/src/services/pipeline.js +++ /dev/null @@ -1,1881 +0,0 @@ -import crypto from "crypto"; -import { - getConversationState, - insertMessage, - insertRun, - touchConversationState, - upsertConversationState, - getRecentMessagesForLLM, - getExternalCustomerIdByChat, - upsertExternalCustomerMap, - updateRunLatency, -} from "../db/repo.js"; -import { sseSend } from "./sse.js"; -import { createWooCustomer, getWooCustomerById } from "./woo.js"; -import { llmExtract, llmPlan } from "./openai.js"; -import { searchProducts } from "./wooProducts.js"; -import { debug as dbg } from "./debug.js"; -import { runTurnV2 } from "./turnEngineV2.js"; -import { runTurnV3 } from "./turnEngineV3.js"; -import { safeNextState } from "./fsm.js"; -import { createOrder, updateOrder } from "./wooOrders.js"; -import { createPreference } from "./mercadoPago.js"; - - -function nowIso() { - return new Date().toISOString(); -} - -function newId(prefix = "run") { - return `${prefix}_${crypto.randomUUID()}`; -} - -const PROMPT_SYSTEM = ` -SYSTEM PROMPT — Butcher Shop WhatsApp Agent - -You are an AI customer support and sales agent for a butcher shop on WhatsApp. Your goals are: help customers clearly, increase sales naturally, and never invent data or actions. You integrate with WooCommerce (products, orders, prices), Postgres (identity and conversation state), RAG (policies, delivery rules), and Mercado Pago (payment links). Never guess information from these systems. - -You receive: wa_chat_id, last_user_message, conversation_history, current_conversation_state, woo_customer_id (if known), and optional tool context. You MUST respect current_conversation_state. If missing, assume IDLE. - -Respond in Spanish. Be warm, concise, local, trustworthy. No technical language. Never mention internal systems. Max 350 characters unless the user explicitly asks for detail. - -Conversation rules - -One message = one action only: ask missing info (max 1–2 questions), confirm an order summary, answer one question, or send payment link + next step. Never ask everything at once. Never repeat the same question or greeting twice. If the user is already ordering, never restart the conversation. - -States (output as next_state) - -Use ONLY these values: IDLE, BROWSING, BUILDING_ORDER, WAITING_ADDRESS, WAITING_PAYMENT, COMPLETED. -IDLE: greeting or general question. -BROWSING: exploring options/prices. -BUILDING_ORDER: defining or modifying basket. -WAITING_ADDRESS: delivery address needed. -WAITING_PAYMENT: order created, waiting for payment. -COMPLETED: payment confirmed. - -Intent (always required) - -Intent describes what the user wants, not the step. Allowed values: ask_recommendation, ask_price, browse_products, create_order, add_item, remove_item, checkout, provide_address, confirm_payment, track_order, other. Use the strongest applicable intent and never downgrade it. - -Product & basket resolution (MANDATORY) - -The shop sells by weight as WooCommerce products/variations or by unit depending of the product. -When the user is ordering and you mention products, you MUST resolve each item to a real WooCommerce product_id (and variation_id if needed) using product lookup tools (e.g. getManyProducts). -If you cannot confidently resolve an ID, do NOT create/update/checkout the order. Ask one clarifying question instead. - -Tool context: products_context - -If products_context is provided (array of products with name/price), you MUST use it as the source of truth for prices and product options. -If the user asks for a price and products_context is empty or has no price, you MUST ask a single clarifying question or say you don't have that price yet. Never invent prices. -Never claim "we don't sell X" unless you actually checked products_context (or asked a clarifying question). If unsure, say you can check and ask for the product name as it appears in the catalog. - -Units / weight (IMPORTANT) - -Some products are sold by weight (kg/g) and others by unit. If products_context includes categories (e.g., bebidas/vinos) infer unit accordingly. -When asking "how much?", ask for kilos for meat cuts, and units for beverages. - -Regional language - -Users may use regional names (AR/UY): "vacío", "tira de asado", "bondiola", "matambre", "nalga", etc. If ambiguous, ask a short clarification with numbered options. - -basket_resolved (MANDATORY) - -When ordering, you MUST output basket_resolved.items with resolved IDs. -Each item must include: product_id (int), variation_id (int or null), quantity (number), unit (kg|g|unit), label (string). -If the user is not ordering, basket_resolved.items must be an empty array. Never leave it empty if you referenced items during an order. - -Order & payment rules - -Only WooCommerce tools can create/update/cancel orders. Never claim an order exists without a real order_id from tools. -If an order is created, a Mercado Pago payment link MUST be generated and sent immediately. Never invent order numbers or links. If tools have not run yet, send a short neutral “hold” message and signal the action via order_action. - -Output format (STRICT) - -You MUST output ONLY valid JSON, no extra text. Required keys every time: reply, next_state, intent, missing_fields, order_action, basket_resolved. -order_action ∈ [none, create, update, cancel, checkout]. -missing_fields is an array (empty if none). -additionalProperties = false. - -Before returning, double-check: did I include all required keys? Did I move the conversation one step forward without repeating myself? -`.trim(); - -function isPriceQuestion(text) { - const t = String(text || "").toLowerCase(); - // Si el usuario está pidiendo "opciones" / "variedades", priorizamos browse y NO price. - if (isBrowseQuestion(t)) return false; - return ( - t.includes("precio") || - t.includes("cuánto") || - t.includes("cuanto") || - t.includes("sale") || - t.includes("vale") || - t.includes("$") || - t.includes("kg") || - t.includes("kilo") - ); -} - -function extractBrowseQuery(text, prev_context = null) { - const raw = String(text || "").toLowerCase(); - const prevLabel = prev_context?.slots?.product_label || null; - - // patrones: "opciones de X", "que opciones de X", "tenes X", "tenes de X" - const m1 = /\bopciones\s+de\s+(.+)$/.exec(raw); - const m2 = /\bten[eé]s\s+de\s+(.+)$/.exec(raw); - const m3 = /\bten[eé]s\s+(.+)$/.exec(raw); - // producto antes del verbo: "asado vendes?", "asado premium hay?" - const m4 = /^(.+?)\s+\b(ten[eé]s|vendes|hay)\b/.exec(raw); - const pick = (m) => - m?.[1] - ? m[1] - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim() - : null; - - let q = pick(m1) || pick(m2) || pick(m3) || pick(m4) || null; - if (q) { - q = q - .replace(/\b(ten[eé]s|vendes|hay|opciones|variedades|tipos)\b/g, " ") - .replace(/\s+/g, " ") - .trim(); - } - if (q && q.length >= 3) return q; - - // si el user dice "dame opciones" y veníamos de un producto, reutilizarlo - if (/\b(dame|pasame|mandame)\s+opciones\b/.test(raw) && prevLabel) return prevLabel; - return prevLabel || null; -} - -function extractProductQuery(text) { - // Heurística simple: remover palabras comunes de pregunta de precio y unidades. - // Esto se puede reemplazar por un extractor LLM más adelante. - const raw = String(text || "") - .toLowerCase() - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim(); - - const stop = new Set([ - "precio", - "precios", - "cuanto", - "cuánto", - "sale", - "vale", - "quiero", - "saber", - "decime", - "decir", - "dime", - "dame", - "pasame", - "mandame", - "tenes", - "tenés", - "vendes", - "hay", - "opciones", - "variedades", - "variedad", - "tipos", - "tipo", - "el", - "la", - "los", - "las", - "de", - "del", - "por", - "un", - "una", - "kg", - "kilo", - "kilos", - "x", - "1", - "1kg", - "medio", - "m", - "g", - "gramos", - "gramo", - ]); - - const tokens = raw.split(" ").filter(Boolean).filter((w) => !stop.has(w)); - const q = tokens.join(" ").trim(); - return q.length >= 3 ? q : raw; // fallback al texto completo si quedó muy corto -} - -function isOrderish(text) { - const t = String(text || "").toLowerCase(); - // heurística: pedidos típicos - if (t.includes("quiero") || t.includes("dame") || t.includes("agrega") || t.includes("agregá")) return true; - // unidades/cantidades - if (/\b\d+([.,]\d+)?\s*(kg|kilo|kilos|g|gramos|u|unidades)\b/i.test(t)) return true; - if (t.includes("kg") || t.includes("kilo") || t.includes("gram")) return true; - return false; -} - -function normTokens(s) { - return String(s || "") - .toLowerCase() - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim() - .split(" ") - .filter(Boolean); -} - -function scoreMatch(label, candidateName) { - const lt = new Set(normTokens(label)); - const nt = normTokens(candidateName); - if (!lt.size || !nt.length) return 0; - let hits = 0; - for (const w of nt) if (lt.has(w)) hits++; - return hits / Math.max(lt.size, 1); -} - -function formatARS(n) { - if (n == null) return null; - const x = Number(n); - if (!Number.isFinite(x)) return null; - return x.toLocaleString("es-AR", { minimumFractionDigits: 0, maximumFractionDigits: 2 }); -} - -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); - - // Heurística: bebidas (vino/cerveza/etc) casi siempre es por unidad. - if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { - return "unit"; - } - return "kg"; -} - -function unitDisplay(unit) { - if (unit === "unit") return "unidad"; - if (unit === "g") return "g"; - return "kilo"; -} - -function askQtyText(unit) { - if (unit === "unit") return "¿Cuántas unidades querés?"; - if (unit === "g") return "¿Cuántos gramos querés?"; - return "¿Cuántos kilos querés?"; -} - -function pricePerText(unit) { - return unit === "unit" ? "por unidad" : "el kilo"; -} - -function parseQuantityUnit(text, defaultUnit = "kg") { - const t = String(text || "").toLowerCase(); - // 2kg / 0.5 kg / 500g / 3 unidades - const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|u|unidad|unidades)\b/.exec(t); - if (m) { - const qty = Number(String(m[1]).replace(",", ".")); - const u = m[2]; - const unit = - u === "g" || u === "gramo" || u === "gramos" - ? "g" - : u === "u" || u === "unidad" || u === "unidades" - ? "unit" - : "kg"; - return Number.isFinite(qty) ? { quantity: qty, unit } : null; - } - // solo número => asumir unidad default - const m2 = /\b(\d+(?:[.,]\d+)?)\b/.exec(t); - if (m2) { - const qty = Number(String(m2[1]).replace(",", ".")); - return Number.isFinite(qty) ? { quantity: qty, unit: defaultUnit } : null; - } - return null; -} - -function makePerf() { - const started_at = Date.now(); - const perf = { t0: started_at, marks: {} }; - const mark = (name) => { - perf.marks[name] = Date.now(); - }; - const msBetween = (a, b) => { - const ta = a === "t0" ? perf.t0 : perf.marks[a]; - const tb = b === "t0" ? perf.t0 : perf.marks[b]; - if (!ta || !tb) return null; - return tb - ta; - }; - return { started_at, perf, mark, msBetween }; -} - -function logStage(enabled, stage, payload) { - if (!enabled) return; - console.log(`[pipeline] ${stage}`, payload); -} - -function isAffirmation(text) { - const t = String(text || "").trim().toLowerCase(); - return ( - t === "si" || - t === "sí" || - t === "si por favor" || - t === "sí por favor" || - t === "dale" || - t === "ok" || - t === "de una" || - t === "por favor" || - t === "confirmo" || - t === "confirmalo" || - t === "confirmá" || - t === "confirmar" - ); -} - -function isBrowseQuestion(text) { - const t = String(text || "").toLowerCase(); - // "qué opciones de vacío tenés", "qué tenés de vacío", "mostrame opciones de ..." - if (/\bopciones\b/.test(t)) return true; - if (/\b(que|qué)\s+ten[eé]s\b/.test(t)) return true; - if (/\b(variedades|variedad|tipos|tipo)\b/.test(t)) return true; - if (/\bmostr(a|ame|ame)\b/.test(t) && /\b(opciones|productos)\b/.test(t)) return true; - // consultas simples de catálogo: "asado tenés?", "asado vendés?", "hay asado premium?" - if (/\b(ten[eé]s|vendes|hay)\b/.test(t)) { - const cleaned = t - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim(); - const stop = new Set([ - "tenes", - "tenés", - "vendes", - "hay", - "de", - "del", - "la", - "el", - "los", - "las", - "un", - "una", - "por", - "favor", - "hola", - "buenas", - "buenos", - "dia", - "día", - "tardes", - "noches", - "precio", - "precios", - ]); - const toks = cleaned.split(" ").filter(Boolean).filter((w) => !stop.has(w)); - if (toks.length >= 1) return true; - } - return false; -} - -function isGreeting(text) { - const t = String(text || "").trim().toLowerCase(); - if (!t) return false; - // saludos típicos y pequeños “small talk” iniciales - if (/^(hola+|buen[oa]s?\s+(d[ií]as|tardes|noches))$/.test(t)) return true; - if (t === "hola" || t === "holaa" || t === "buen dia" || t === "buen día") return true; - if (/\bcomo\s+estas\b|\bc[oó]mo\s+est[aá]s\b/.test(t)) return true; - return false; -} - -async function ensureWooCustomerId({ - tenantId, - chat_id, - displayName, - from, - externalCustomerId, - run_id, -}) { - let updatedId = externalCustomerId; - let error = null; - try { - if (updatedId) { - const found = await getWooCustomerById({ tenantId, id: updatedId }); - if (!found) { - const phone = chat_id.replace(/@.+$/, ""); - const name = displayName || from || phone; - const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); - if (!created?.id) throw new Error("woo_customer_id_missing"); - updatedId = await upsertExternalCustomerMap({ - tenant_id: tenantId, - wa_chat_id: chat_id, - external_customer_id: created?.id, - provider: "woo", - }); - } else { - updatedId = await upsertExternalCustomerMap({ - tenant_id: tenantId, - wa_chat_id: chat_id, - external_customer_id: updatedId, - provider: "woo", - }); - } - } else { - const phone = chat_id.replace(/@.+$/, ""); - const name = displayName || from || phone; - const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); - if (!created?.id) throw new Error("woo_customer_id_missing"); - updatedId = await upsertExternalCustomerMap({ - tenant_id: tenantId, - wa_chat_id: chat_id, - external_customer_id: created?.id, - provider: "woo", - }); - } - } catch (e) { - error = { - message: String(e?.message || e), - status: e?.status || e?.cause?.status || null, - code: e?.body?.code || e?.cause?.body?.code || null, - run_id: run_id || null, - }; - } - return { external_customer_id: updatedId, error }; -} - -function classifyIntent(text, prev_context = null) { - // clasificador "rápido" (reglas). Si después querés, lo reemplazamos por un LLM mini. - const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; - const hasPending = Boolean(prev.pending_clarification?.candidates?.length); - const hasBasket = Array.isArray(prev.order_basket?.items) && prev.order_basket.items.length > 0; - const awaitingPrice = Boolean(prev.awaiting_price?.labels?.length) || Boolean(prev.pending_item); - - if (isAffirmation(text) && (hasPending || hasBasket || awaitingPrice)) { - return { kind: awaitingPrice ? "price" : "order", intent_hint: awaitingPrice ? "ask_price" : "create_order", needs_extract: false, needs_products: awaitingPrice, is_continuation: true }; - } - if (isOrderish(text)) { - return { kind: "order", intent_hint: "create_order", needs_extract: true, needs_products: true }; - } - // Browse ANTES que price: "opciones de asado ... buscando precios" es browse (listado) + luego precio por item. - if (isBrowseQuestion(text)) { - return { kind: "browse", intent_hint: "browse_products", needs_extract: false, needs_products: true }; - } - if (isPriceQuestion(text)) { - return { kind: "price", intent_hint: "ask_price", needs_extract: true, needs_products: true }; - } - return { kind: "other", intent_hint: "other", needs_extract: false, needs_products: false }; -} - -function tokenize(s) { - return String(s || "") - .toLowerCase() - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim() - .split(" ") - .filter(Boolean); -} - -function candidateText(c) { - const parts = [c?.name || ""]; - if (Array.isArray(c?.categories)) { - for (const cat of c.categories) { - if (cat?.name) parts.push(cat.name); - if (cat?.slug) parts.push(cat.slug); - } - } - if (Array.isArray(c?.attributes)) { - for (const a of c.attributes) { - if (a?.name) parts.push(a.name); - if (Array.isArray(a?.options)) parts.push(a.options.join(" ")); - } - } - return parts.join(" "); -} - -function parseNegatedTokens(userText) { - const t = tokenize(userText); - const neg = new Set(); - for (let i = 0; i < t.length - 1; i++) { - if (t[i] === "no") neg.add(t[i + 1]); - } - return neg; -} - -function scoreUserMatch(userText, candidate) { - const ut = new Set(tokenize(userText)); - const neg = parseNegatedTokens(userText); - const ct = new Set(tokenize(candidateText(candidate))); - let overlap = 0; - let negOverlap = 0; - for (const w of ut) if (ct.has(w)) overlap++; - for (const w of neg) if (ct.has(w)) negOverlap++; - return overlap - negOverlap * 2; -} - -function computeDifferentiators(candidates) { - // tokens que distinguen cada candidato vs el resto (para mostrar opciones humanas) - const tokenLists = candidates.map((c) => tokenize(candidateText(c))); - const tokenSets = tokenLists.map((t) => new Set(t)); - - return candidates.map((c, idx) => { - const mine = tokenSets[idx]; - const others = new Set(); - for (let j = 0; j < tokenSets.length; j++) { - if (j === idx) continue; - for (const w of tokenSets[j]) others.add(w); - } - const unique = [...mine].filter((w) => !others.has(w)).slice(0, 4); - return { id: c?.woo_product_id, name: c?.name, unique_tokens: unique }; - }); -} - -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) - ); -} - -function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) { - const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name); - const off = Math.max(0, parseInt(candidateOffset, 10) || 0); - const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9)); - - const slice = cands.slice(off, off + size); - const diffs = computeDifferentiators(cands); - - const options = slice.map((c, i) => { - const d = diffs.find((z) => z.id === c.woo_product_id); - const hint = d?.unique_tokens?.length ? ` (${d.unique_tokens.join(", ")})` : ""; - return { idx: baseIdx + i, type: "product", woo_product_id: c.woo_product_id, name: c.name, hint }; - }); - - const hasMore = off + size < cands.length; - if (hasMore) { - options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" }); - } - - const list = options - .map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}${o.hint}`)) - .join("\n"); - - const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`; - const pending = { - candidates: cands, - options, - candidate_offset: off, - page_size: size, - base_idx: baseIdx, - has_more: hasMore, - next_candidate_offset: off + size, - next_base_idx: baseIdx + size + (hasMore ? 1 : 0), - }; - return { question, pending, options, hasMore }; -} - -function resolveAmbiguity({ userText, candidates }) { - const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name); - if (cands.length <= 1) { - return { kind: "resolved", chosen: cands[0] || null, pending: null }; - } - - const scored = cands - .map((c) => ({ c, s: scoreUserMatch(userText, c) })) - .sort((a, b) => b.s - a.s); - - const best = scored[0]; - const second = scored[1]; - - // Resolver si hay señal clara (>=2) o diferencia >=2 - const confident = best.s >= 2 || (second && best.s - second.s >= 2); - if (confident) { - return { kind: "resolved", chosen: best.c, pending: null, debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) } }; - } - - const full = scored.map((x) => x.c).slice(0, 60); - const { question, pending } = buildPagedOptions({ candidates: full, candidateOffset: 0, baseIdx: 1, pageSize: 9 }); - - return { - kind: "ask", - question, - pending, - debug: { scored: scored.map((x) => ({ id: x.c.woo_product_id, s: x.s })) }, - }; -} - -function parseOptionSelection(userText) { - const t = String(userText || "").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; -} - -function reduceConversationContext(prevContext, { text, extracted, products_context }) { - const prev = prevContext && typeof prevContext === "object" ? prevContext : {}; - const next = { ...prev }; - - // Slots (mínimos) - const slots = { ...(prev.slots || {}) }; - - // Unidad - if (/\bkg\b|\bkilo\b|\bkilos\b/i.test(text)) slots.unit = "kg"; - if (/\bg\b|\bgramo\b|\bgramos\b/i.test(text)) slots.unit = "g"; - - // Producto “label” (si extractor encontró uno) - const firstLabel = extracted?.items?.[0]?.label; - if (firstLabel) slots.product_label = firstLabel; - - next.slots = slots; - - // Guardar último products_context para debugging y para colapsos - if (products_context) next.last_products_context = products_context; - - return next; -} - -function mergeBasket(prevBasket, nextBasket) { - const prevItems = Array.isArray(prevBasket?.items) ? prevBasket.items : []; - const nextItems = Array.isArray(nextBasket?.items) ? nextBasket.items : []; - const byKey = new Map(); - - for (const it of prevItems) { - const k = `${it.product_id}:${it.variation_id ?? "null"}:${it.unit}`; - byKey.set(k, { ...it }); - } - for (const it of nextItems) { - const k = `${it.product_id}:${it.variation_id ?? "null"}:${it.unit}`; - // si viene el mismo item, pisamos cantidad/label (último gana) - byKey.set(k, { ...it }); - } - - return { items: [...byKey.values()] }; -} - -async function extractProducts({ - tenantId, - text, - llmInput, - classification, - mark, - resolveDebug, - stageDebug, -}) { - // Product Extractor (estructurado) + resolver IDs (Postgres->Woo) - let extracted = null; - let resolvedBasket = null; - let unresolved = null; - - if (!classification.needs_extract) { - return { extracted, resolvedBasket, unresolved, products_context: null }; - } - - mark("before_llmExtract"); - try { - const out = await llmExtract({ input: llmInput }); - extracted = out.extracted; - logStage(stageDebug, "extractProducts.extracted", extracted); - } catch (e) { - extracted = { intent: classification.intent_hint || "other", items: [] }; - llmInput._extract_error = String(e?.message || e); - logStage(stageDebug, "extractProducts.error", { error: llmInput._extract_error }); - } finally { - mark("after_llmExtract"); - } - - if (extracted?.items?.length) { - mark("before_resolve_items"); - resolvedBasket = { items: [] }; - unresolved = []; - - for (const it of extracted.items) { - try { - // Para ambigüedades queremos más candidatos (no 3), así el paginado 9+1 tiene con qué. - const { items } = await searchProducts({ tenantId, q: it.label, limit: 25 }); - if (!items?.length) { - unresolved.push({ ...it, reason: "not_found" }); - continue; - } - - const scored = items - .map((p) => ({ p, s: scoreMatch(it.label, p.name) })) - .sort((a, b) => b.s - a.s); - - const best = scored[0]; - const second = scored[1]; - const ambiguous = second && best.s > 0 && Math.abs(best.s - second.s) < 0.15; - - if (resolveDebug) { - console.log("[resolve] candidates", { - label: it.label, - scored: scored.map((x) => ({ id: x.p.woo_product_id, name: x.p.name, price: x.p.price, s: x.s })), - ambiguous, - }); - } - - if (ambiguous || best.s === 0) { - unresolved.push({ - ...it, - reason: "ambiguous", - candidates: scored - .slice(0, 25) - .map((x) => ({ - woo_product_id: x.p.woo_product_id, - name: x.p.name, - price: x.p.price, - attributes: x.p.attributes || [], - })), - }); - continue; - } - - resolvedBasket.items.push({ - product_id: Number(best.p.woo_product_id), - variation_id: null, - quantity: it.quantity, - unit: it.unit, - label: it.label, - }); - } catch (e) { - unresolved.push({ ...it, reason: "lookup_error", error: String(e?.message || e) }); - } - } - - llmInput.extracted = extracted; - llmInput.resolved_basket = resolvedBasket; - llmInput.unresolved_items = unresolved; - mark("after_resolve_items"); - } - - // products_context para precios / browse (soporta múltiples labels) - let products_context = null; - if (classification.needs_products || classification.kind === "price" || isPriceQuestion(text) || classification.kind === "browse") { - const labels = (extracted?.items || []).map((x) => x.label).filter(Boolean); - const prevLabel = llmInput?.context?.slots?.product_label || null; - const q0 = classification.kind === "browse" - ? (extractBrowseQuery(text, llmInput?.context) || extractProductQuery(text)) - : extractProductQuery(text); - const baseQueries = labels.length ? labels.slice(0, 3) : [prevLabel, q0].filter(Boolean); - const queries = baseQueries.length ? baseQueries.slice(0, 3) : [q0].filter(Boolean); - - mark("before_product_lookup"); - try { - const results = []; - for (const q of queries) { - const lim = classification.kind === "browse" ? 25 : 12; - const forceWoo = classification.kind === "browse" || classification.kind === "price"; - const { items, source } = await searchProducts({ tenantId, q, limit: lim, forceWoo }); - results.push({ label: q, source, items }); - } - // flatten para UI/LLM - const perLabel = classification.kind === "browse" ? 25 : 12; - const flat = results.flatMap((r) => (r.items || []).slice(0, perLabel).map((p) => ({ ...p, _query: r.label }))); - products_context = { queries, results, items: flat }; - llmInput.products_context = products_context; - logStage(stageDebug, "extractProducts.products_context", { queries, results: results.map((r) => ({ q: r.label, count: r.items?.length || 0 })) }); - } catch (e) { - products_context = { queries, error: String(e?.message || e) }; - llmInput.products_context = products_context; - logStage(stageDebug, "extractProducts.products_context_error", products_context); - } finally { - mark("after_product_lookup"); - } - } - - return { extracted, resolvedBasket, unresolved, products_context }; -} - -function applyBusinessLogic({ - text, - classification, - extracted, - resolvedBasket, - unresolved, - products_context, - prev_context, - stageDebug, -}) { - // Business Logic: decide si pedir aclaración / responder precio server-side / pasar al LLM final. - // IMPORTANTE: acá NO se llama al LLM, solo se decide. - - const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; - const isPriceFlow = - classification.kind === "price" || - extracted?.intent === "ask_price" || - Boolean(prev.awaiting_price?.labels?.length) || - Boolean(prev.pending_item) || - isPriceQuestion(text); - - // 0) Si hay una ambigüedad pendiente, intentamos colapsarla con la respuesta del usuario - // Nota: esta rama debe tener prioridad sobre pending_item para evitar casos tipo: - // el bot mostró opciones y el usuario responde "4" => selección (NO cantidad en kg). - if (prev.pending_clarification?.candidates?.length) { - // Selección explícita por número (1/2/3) - const sel = parseOptionSelection(text); - if (sel && Array.isArray(prev.pending_clarification.options)) { - const opt = prev.pending_clarification.options.find((o) => o.idx === sel); - if (opt) { - if (opt.type === "more") { - const nextOffset = prev.pending_clarification.next_candidate_offset ?? ((prev.pending_clarification.candidate_offset || 0) + (prev.pending_clarification.page_size || 9)); - const nextBaseIdx = prev.pending_clarification.next_base_idx ?? ((prev.pending_clarification.base_idx || 1) + (prev.pending_clarification.page_size || 9) + 1); - const { question, pending } = buildPagedOptions({ - candidates: prev.pending_clarification.candidates, - candidateOffset: nextOffset, - baseIdx: nextBaseIdx, - pageSize: prev.pending_clarification.page_size || 9, - }); - const decision = { - mode: "ask_clarification", - reply: question, - next_state: "BROWSING", - intent: extracted?.intent || classification.intent_hint || "browse_products", - missing_fields: ["product_selection"], - order_action: "none", - basket_resolved: resolvedBasket || { items: [] }, - meta: { paged: true, via: "more_option" }, - context_patch: { pending_clarification: pending, pending_item: null }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - const chosen = - prev.pending_clarification.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null; - if (chosen) { - const price = chosen.price ?? null; - const unit = inferDefaultUnit({ name: chosen.name, categories: chosen.categories }); - const decision = { - mode: "resolved_from_pending", - resolved_product: chosen, - reply: - price != null - ? `Perfecto. ${chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}` - : `Perfecto. ${askQtyText(unit)} de ${chosen.name}?`, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [] }, - meta: { collapsed: true, via: "option_number" }, - context_patch: { - pending_clarification: null, - awaiting_price: null, - pending_item: { - product_id: Number(chosen.woo_product_id), - variation_id: null, - name: chosen.name, - price: chosen.price ?? null, - categories: chosen.categories || [], - attributes: chosen.attributes || [], - default_unit: unit, - }, - }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - } - } - - // “mostrame más” sin número - if (isShowMoreRequest(text)) { - const nextOffset = prev.pending_clarification.next_candidate_offset ?? ((prev.pending_clarification.candidate_offset || 0) + (prev.pending_clarification.page_size || 9)); - const nextBaseIdx = prev.pending_clarification.next_base_idx ?? ((prev.pending_clarification.base_idx || 1) + (prev.pending_clarification.page_size || 9) + 1); - const { question, pending } = buildPagedOptions({ - candidates: prev.pending_clarification.candidates, - candidateOffset: nextOffset, - baseIdx: nextBaseIdx, - pageSize: prev.pending_clarification.page_size || 9, - }); - const decision = { - mode: "ask_clarification", - reply: question, - next_state: "BROWSING", - intent: extracted?.intent || classification.intent_hint || "browse_products", - missing_fields: ["product_selection"], - order_action: "none", - basket_resolved: resolvedBasket || { items: [] }, - meta: { paged: true, via: "more_text" }, - context_patch: { pending_clarification: pending, pending_item: null }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - const r = resolveAmbiguity({ userText: text, candidates: prev.pending_clarification.candidates }); - if (r.kind === "resolved" && r.chosen) { - // si era un price inquiry, resolvemos precio directo en base a ese candidato - const price = r.chosen.price ?? null; - const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories }); - const decision = { - mode: "resolved_from_pending", - resolved_product: r.chosen, - reply: price != null - ? `Perfecto. ${r.chosen.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}` - : `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [] }, - meta: { collapsed: true, debug: r.debug || null }, - context_patch: { - pending_clarification: null, - awaiting_price: null, - pending_item: { - product_id: Number(r.chosen.woo_product_id), - variation_id: null, - name: r.chosen.name, - price: r.chosen.price ?? null, - categories: r.chosen.categories || [], - attributes: r.chosen.attributes || [], - default_unit: unit, - }, - }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - if (r.kind === "ask") { - const decision = { - mode: "ask_clarification", - reply: r.question, - next_state: "BUILDING_ORDER", - intent: extracted?.intent || classification.intent_hint || "create_order", - missing_fields: ["product_selection"], - order_action: "none", - basket_resolved: resolvedBasket || { items: [] }, - meta: { skipped: "pending_still_ambiguous", debug: r.debug || null }, - context_patch: { pending_clarification: r.pending, awaiting_price: isPriceFlow ? { labels: [prev?.slots?.product_label].filter(Boolean) } : null, pending_item: null }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - } - - // 0a) Si venimos de una selección (pending_item) y el usuario responde cantidad/unidad, - // completamos basket_resolved y confirmamos sin volver a preguntar por "qué producto". - if (prev.pending_item) { - const defUnit = inferDefaultUnit({ name: prev.pending_item.name, categories: prev.pending_item.categories }); - const parsed = parseQuantityUnit(text, defUnit); - if (parsed?.quantity) { - const it = { - product_id: Number(prev.pending_item.product_id), - variation_id: prev.pending_item.variation_id ?? null, - quantity: parsed.quantity, - unit: parsed.unit, - label: prev.pending_item.name, - }; - const qtyStr = - parsed.unit === "unit" - ? `${parsed.quantity}u` - : parsed.unit === "g" - ? `${parsed.quantity}g` - : `${parsed.quantity}kg`; - const decision = { - mode: "resolved_from_pending", - reply: `Perfecto, anoto ${qtyStr} de ${prev.pending_item.name}. ¿Algo más?`, - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [it] }, - meta: { server: "pending_item_qty" }, - context_patch: { - pending_item: null, - pending_clarification: null, - awaiting_price: null, - order_basket: mergeBasket(prev.order_basket, { items: [it] }), - }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - } - - // 0b) Browse: "qué opciones de X tenés" => listar opciones server-side y dejar pending_clarification - if (classification.kind === "browse" && products_context?.items?.length) { - const uniq = new Map(); - for (const p of products_context.items) { - if (!p?.woo_product_id) continue; - if (!uniq.has(p.woo_product_id)) uniq.set(p.woo_product_id, p); - } - const candidates = [...uniq.values()].slice(0, 60).map((p) => ({ - woo_product_id: p.woo_product_id, - name: p.name, - price: p.price ?? null, - attributes: p.attributes || [], - })); - const label = (products_context.queries || []).join(" / ") || "ese producto"; - const { question, pending } = buildPagedOptions({ candidates, candidateOffset: 0, baseIdx: 1, pageSize: 9 }); - - const decision = { - mode: "ask_clarification", - reply: `Tengo estas opciones de "${label}":\n${question.split("\n").slice(1).join("\n")}`, - next_state: "BROWSING", - intent: "browse_products", - missing_fields: ["product_confirmation"], - order_action: "none", - basket_resolved: { items: [] }, - meta: { server: "browse_options" }, - // Importante: al entrar en modo selección por lista, anulamos pending_item viejo para que un "4" no se tome como cantidad. - context_patch: { pending_clarification: pending, pending_item: null }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - // 1) Ambiguo / sin resolver => 1 pregunta (pero guardando candidatos para colapsar luego) - if (unresolved?.length) { - const first = unresolved[0]; - - // Intentar colapsar en el mismo turno si el texto ya trae distinción ("no curada", "cerdo", etc.) - const r = first.reason === "ambiguous" && Array.isArray(first.candidates) - ? resolveAmbiguity({ userText: text, candidates: first.candidates }) - : null; - - if (r?.kind === "resolved" && r.chosen) { - const unit = inferDefaultUnit({ name: r.chosen.name, categories: r.chosen.categories }); - const decision = { - mode: "resolved_from_pending", - resolved_product: r.chosen, - reply: r.chosen.price != null - ? `Perfecto. ${r.chosen.name} está $${formatARS(r.chosen.price)} ${pricePerText(unit)}. ${askQtyText(unit)}` - : `Perfecto. ${askQtyText(unit)} de ${r.chosen.name}?`, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [] }, - meta: { collapsed: true, debug: r.debug || null }, - context_patch: { - pending_clarification: null, - pending_item: { - product_id: Number(r.chosen.woo_product_id), - variation_id: null, - name: r.chosen.name, - price: r.chosen.price ?? null, - categories: r.chosen.categories || [], - attributes: r.chosen.attributes || [], - default_unit: unit, - }, - }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - // Si no encontramos nada, preguntar mejor (no “¿a cuál te referís?”) - if (first.reason === "not_found") { - const decision = { - mode: "ask_clarification", - reply: `No encontré "${first.label}" en el catálogo. ¿Podés decirme si es de cerdo o vacuna, o mandarme el nombre como figura en la web?`, - next_state: "BROWSING", - intent: extracted?.intent || classification.intent_hint || "ask_price", - missing_fields: ["product_label"], - order_action: "none", - basket_resolved: resolvedBasket || { items: [] }, - meta: { skipped: "not_found" }, - context_patch: { pending_clarification: null, awaiting_price: isPriceFlow ? { labels: [first.label] } : null }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - const q = r?.kind === "ask" ? r.question : `¿Cuál de estos querés?`; - - const decision = { - mode: "ask_clarification", - reply: q, - next_state: "BUILDING_ORDER", - intent: extracted?.intent || classification.intent_hint || "create_order", - missing_fields: ["product_selection"], - order_action: "none", - basket_resolved: resolvedBasket || { items: [] }, - meta: { skipped: "unresolved_items", unresolved_count: unresolved.length }, - context_patch: { - pending_clarification: r?.pending || ( - first.reason === "ambiguous" && Array.isArray(first.candidates) - ? { label: first.label, candidates: first.candidates } - : { label: first.label, candidates: [] } - ), - awaiting_price: isPriceFlow ? { labels: [first.label] } : null, - }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - // 2) Precio: siempre server-side (si no, el LLM inventa o pregunta cosas raras) - if (isPriceFlow && products_context && Array.isArray(products_context.items)) { - const uniq = new Map(); - for (const p of products_context.items) { - if (!p?.woo_product_id || !p?.name) continue; - if (!uniq.has(p.woo_product_id)) uniq.set(p.woo_product_id, p); - } - const candidates = [...uniq.values()].slice(0, 60).map((p) => ({ - woo_product_id: p.woo_product_id, - name: p.name, - price: p.price ?? null, - categories: p.categories || [], - attributes: p.attributes || [], - })); - - // Si hay un único candidato, respondemos el precio directo (y guardamos pending_item para cantidad) - if (candidates.length === 1) { - const c = candidates[0]; - const price = c.price ?? null; - const unit = inferDefaultUnit({ name: c.name, categories: c.categories }); - const decision = { - mode: "server_price", - reply: price != null - ? `Perfecto. ${c.name} está $${formatARS(price)} ${pricePerText(unit)}. ${askQtyText(unit)}` - : `Perfecto. ${askQtyText(unit)} de ${c.name}?`, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [] }, - meta: { server: "price_single_candidate" }, - context_patch: { - pending_clarification: null, - awaiting_price: null, - pending_item: { - product_id: Number(c.woo_product_id), - variation_id: null, - name: c.name, - price: c.price ?? null, - categories: c.categories || [], - attributes: c.attributes || [], - default_unit: unit, - }, - }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - if (candidates.length) { - const { question, pending } = buildPagedOptions({ candidates, candidateOffset: 0, baseIdx: 1, pageSize: 9 }); - const label = - (Array.isArray(products_context.queries) ? products_context.queries.join(" / ") : null) || - extracted?.items?.[0]?.label || - "ese producto"; - const decision = { - mode: "ask_clarification", - reply: `Para cotizar "${label}", ¿cuál es?\n${question.split("\n").slice(1).join("\n")}`, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: ["product_selection"], - order_action: "none", - basket_resolved: { items: [] }, - meta: { server: "price_pick_product" }, - context_patch: { pending_clarification: pending, awaiting_price: { labels: [label] } }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - const label = - (Array.isArray(products_context.queries) ? products_context.queries.join(" / ") : null) || - products_context.query || - extracted?.items?.[0]?.label || - "ese producto"; - const decision = { - mode: "server_hold_price", - reply: `Dale, confirmo el precio de "${label}". ¿Lo querés por kilo?`, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: ["product_confirmation"], - order_action: "none", - basket_resolved: { items: [] }, - meta: { skipped: "price_no_real_value" }, - context_patch: { pending_clarification: null, awaiting_price: { labels: Array.isArray(products_context.queries) ? products_context.queries : [products_context.query].filter(Boolean) } }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; - } - - // 3) Default: dejar que el LLM genere el plan final, pero con basket_resolved ya fijo si existe. - const decision = { - mode: "llm_final", - next_state: null, - intent: null, - missing_fields: null, - order_action: null, - basket_resolved: resolvedBasket || null, - meta: null, - context_patch: { pending_clarification: null }, - }; - logStage(stageDebug, "applyBusinessLogic", decision); - return decision; -} - -async function composeReply({ text, llmInput, decision, resolvedBasket, mark, stageDebug }) { - // LLM solo para respuesta final (cuando corresponde) - mark("before_llmPlan"); - try { - if (decision.mode === "ask_clarification" || decision.mode === "server_price" || decision.mode === "server_hold_price") { - const plan = { - reply: decision.reply, - next_state: decision.next_state, - intent: decision.intent, - missing_fields: decision.missing_fields, - order_action: decision.order_action, - basket_resolved: decision.basket_resolved, - }; - const llmMeta = decision.meta || { skipped: decision.mode }; - logStage(stageDebug, "composeReply.server", { mode: decision.mode }); - return { plan, llmMeta }; - } - - if (decision.mode === "resolved_from_pending") { - const plan = { - reply: decision.reply, - next_state: decision.next_state, - intent: decision.intent, - missing_fields: decision.missing_fields, - order_action: decision.order_action, - basket_resolved: decision.basket_resolved, - }; - const llmMeta = decision.meta || { skipped: decision.mode }; - logStage(stageDebug, "composeReply.server", { mode: decision.mode }); - return { plan, llmMeta }; - } - - const out = await llmPlan({ promptSystem: PROMPT_SYSTEM, input: llmInput }); - const plan = out.plan; - if (resolvedBasket?.items?.length) plan.basket_resolved = resolvedBasket; - - // Guardrail anti placeholders: si el modelo devolvió $X/$Y/$Z, lo reemplazamos por respuesta server-side - // (o por hold) usando products_context. - if (/\$\s*[XYZ]\b/i.test(plan.reply || "")) { - const pc = llmInput.products_context; - if (pc && Array.isArray(pc.items)) { - const decision2 = applyBusinessLogic({ - text, - classification: { kind: "price", intent_hint: "ask_price" }, - extracted: llmInput.extracted || null, - resolvedBasket: resolvedBasket || null, - unresolved: null, - products_context: pc, - prev_context: llmInput.context || {}, - stageDebug, - }); - const out2 = await composeReply({ text, llmInput, decision: decision2, resolvedBasket, mark, stageDebug }); - return out2; - } - // sin precios reales, hold humano - plan.reply = `Dale, lo reviso y te confirmo el precio en un momento. ¿Lo querés por kilo?`; - plan.intent = plan.intent || "ask_price"; - plan.next_state = plan.next_state || "BROWSING"; - } - - // Guardrail anti alucinación de precios: si el LLM imprimió $ + número pero NO tenemos products_context, - // lo reemplazamos por hold para no afirmar precios inventados. - if (/\$\s*\d/.test(plan.reply || "") && !llmInput.products_context) { - plan.reply = `Dale, lo reviso y te confirmo el precio en un momento. ¿Lo querés por kilo?`; - plan.intent = "ask_price"; - plan.next_state = "BROWSING"; - plan.order_action = "none"; - plan.missing_fields = ["product_confirmation"]; - plan.basket_resolved = { items: [] }; - } - - logStage(stageDebug, "composeReply.llm", { model: out.model }); - return { plan, llmMeta: { model: out.model, usage: out.usage, raw_text: out.raw_text } }; - } catch (err) { - const plan = { - reply: `Recibido: "${text}". ¿Querés retiro o envío?`, - next_state: "BUILDING_ORDER", - intent: "create_order", - missing_fields: ["delivery_or_pickup"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const llmMeta = { error: String(err?.message || err), code: err?.code || null }; - console.warn("[llm] fallback", { code: llmMeta.code, error: llmMeta.error }); - return { plan, llmMeta }; - } finally { - mark("after_llmPlan"); - } -} - -async function buildLlmInput({ - tenantId, - chat_id, - text, - prev_state, - prev_context, - externalCustomerId, - mark, -}) { - const history = await getRecentMessagesForLLM({ - tenant_id: tenantId, - wa_chat_id: chat_id, - limit: 20, - }); - const compactHistory = collapseAssistantMessages(history); - mark("after_getRecentMessagesForLLM_for_plan"); - - return { - wa_chat_id: chat_id, - last_user_message: text, - conversation_history: compactHistory, - current_conversation_state: prev_state, - context: { - ...(prev_context || {}), - external_customer_id: externalCustomerId ?? prev_context?.external_customer_id ?? null, - }, - }; -} - -function sanitizeIntentAndState({ plan, text, classification, prev_state }) { - // Guardrails para que state/intent no queden “raros” en small talk. - // Nota: esto no toca el reply (solo metadata de control). - if (!plan || typeof plan !== "object") return plan; - - // Saludos: mantener IDLE y no “inventar” intent/estado - if (isGreeting(text) && classification?.kind === "other") { - plan.intent = "other"; - plan.order_action = "none"; - plan.missing_fields = []; - plan.basket_resolved = { items: [] }; - plan.next_state = prev_state || "IDLE"; - if (plan.next_state !== "IDLE") plan.next_state = "IDLE"; - return plan; - } - - // Si el clasificador detectó price/order, evitamos que el LLM degrade a OTHER sin motivo - if (classification?.kind === "price" && plan.intent === "other") plan.intent = "ask_price"; - if (classification?.kind === "order" && plan.intent === "other") plan.intent = "create_order"; - - // Si estamos en order, no permitir que next_state vuelva a IDLE (a menos que sea explícito) - if (classification?.kind === "order" && prev_state && prev_state !== "IDLE" && plan.next_state === "IDLE") { - plan.next_state = prev_state; - } - - return plan; -} - -export async function processMessage({ - tenantId, - chat_id, - from, - text, - provider, - message_id, - displayName = null, - meta = null, -}) { - const { started_at, mark, msBetween } = makePerf(); - - // “Touch” temprano para que la conversación aparezca en la columna izquierda - // incluso si este run tarda o falla. - await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id }); - - mark("start"); - const stageDebug = dbg.perf; - const prev = await getConversationState(tenantId, chat_id); - mark("after_getConversationState"); - const isStale = - prev?.state_updated_at && - Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; - const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; - let externalCustomerId = await getExternalCustomerIdByChat({ - tenant_id: tenantId, - wa_chat_id: chat_id, - provider: "woo", - }); - mark("after_getExternalCustomerIdByChat"); - - await insertMessage({ - tenant_id: tenantId, - wa_chat_id: chat_id, - provider, - message_id, - direction: "in", - text, - payload: { raw: { from, text, meta } }, - run_id: null, - }); - mark("after_insertMessage_in"); - - mark("before_classifyIntent"); - const turnEngine = String(process.env.TURN_ENGINE || "").toLowerCase(); - const useTurnV2 = turnEngine === "v2"; - const useTurnV3 = turnEngine === "v3"; - const classification = useTurnV2 || useTurnV3 ? { kind: "other", intent_hint: "other" } : classifyIntent(text, prev?.context); - mark("after_classifyIntent"); - logStage(stageDebug, "classifyIntent", classification); - - const llmInput = await buildLlmInput({ - tenantId, - chat_id, - text, - prev_state, - prev_context: prev?.context, - externalCustomerId, - mark, - }); - logStage(stageDebug, "llmInput.base", { has_history: Array.isArray(llmInput.conversation_history), state: llmInput.current_conversation_state }); - - let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {}; - let decision; - let plan; - let llmMeta; - let resolvedBasket = null; - let tools = []; - - if (useTurnV3) { - mark("before_turn_v3"); - const out = await runTurnV3({ - tenantId, - chat_id, - text, - prev_state, - prev_context: reducedContext, - conversation_history: llmInput.conversation_history || [], - }); - plan = out.plan; - decision = out.decision || { context_patch: {}, actions: [], audit: {} }; - llmMeta = { kind: "nlu_v3", audit: decision.audit || null }; - tools = []; - mark("after_turn_v3"); - } else if (useTurnV2) { - mark("before_turn_v2"); - const out = await runTurnV2({ - tenantId, - chat_id, - text, - prev_state, - prev_context: reducedContext, - conversation_history: llmInput.conversation_history || [], - }); - plan = out.plan; - llmMeta = out.llmMeta; - decision = out.decision || { context_patch: {} }; - mark("after_turn_v2"); - } else { - // Reducer de contexto: consolidar slots y evitar “olvidos” - mark("before_reduceContext"); - const resolveDebug = dbg.resolve; - const { extracted, resolvedBasket: rb, unresolved, products_context } = await extractProducts({ - tenantId, - text, - llmInput, - mark, - resolveDebug, - classification, - stageDebug, - }); - resolvedBasket = rb || null; - reducedContext = reduceConversationContext(prev?.context, { text, extracted, products_context: llmInput.products_context || products_context }); - mark("after_reduceContext"); - logStage(stageDebug, "reduceContext", reducedContext?.slots || {}); - - // Persistimos canasta consolidada (para no "perder" items confirmados entre turnos) - if (resolvedBasket?.items?.length) { - reducedContext.order_basket = mergeBasket(reducedContext.order_basket, resolvedBasket); - } - - // (extra) por si el extractor no corrió, pero igual es pregunta de precio - if (!products_context && classification.kind === "price") { - await extractProducts({ - tenantId, - text, - llmInput, - classification: { ...classification, needs_extract: false, kind: "price" }, - mark, - resolveDebug, - stageDebug, - }); - } - - mark("before_applyBusinessLogic"); - decision = applyBusinessLogic({ - text, - classification, - extracted, - resolvedBasket, - unresolved, - products_context: llmInput.products_context || products_context, - prev_context: reducedContext, - stageDebug, - }); - mark("after_applyBusinessLogic"); - - const out = await composeReply({ - text, - llmInput, - decision, - resolvedBasket, - mark, - stageDebug, - }); - plan = out.plan; - llmMeta = out.llmMeta; - } - - sanitizeIntentAndState({ plan, text, classification, prev_state }); - - const runStatus = llmMeta?.error ? "warn" : "ok"; - const isSimulated = provider === "sim" || meta?.source === "sim"; - - const invariants = { - ok: true, - checks: [ - { name: "required_keys_present", ok: true }, - { name: "no_checkout_without_payment_link", ok: true }, - { name: "no_order_action_without_items", ok: true }, - ], - }; - mark("before_insertRun"); - - // --- Ejecutar acciones determinísticas (solo v3) --- - let actionPatch = {}; - if (useTurnV3 && Array.isArray(decision?.actions) && decision.actions.length) { - const newTools = []; - const actions = decision.actions; - - const calcOrderTotal = (order) => { - const rawTotal = Number(order?.raw?.total); - if (Number.isFinite(rawTotal) && rawTotal > 0) return rawTotal; - const items = Array.isArray(order?.line_items) ? order.line_items : []; - let sum = 0; - for (const it of items) { - const t = Number(it?.total); - if (Number.isFinite(t)) sum += t; - } - return sum > 0 ? sum : null; - }; - - // Asegurar Woo customer si vamos a crear orden - const needsWoo = actions.some((a) => a.type === "create_order" || a.type === "update_order"); - if (needsWoo) { - const ensured = await ensureWooCustomerId({ - tenantId, - chat_id, - displayName, - from, - externalCustomerId, - }); - externalCustomerId = ensured.external_customer_id; - if (ensured.error) { - newTools.push({ type: "ensure_woo_customer", ok: false, error: ensured.error }); - } else { - newTools.push({ type: "ensure_woo_customer", ok: true, external_customer_id: externalCustomerId }); - } - } - - for (const act of actions) { - try { - if (act.type === "create_order") { - const order = await createOrder({ - tenantId, - wooCustomerId: externalCustomerId, - basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] }, - address: reducedContext?.delivery_address || reducedContext?.address || null, - run_id: null, - }); - actionPatch.woo_order_id = order?.id || null; - actionPatch.order_total = calcOrderTotal(order); - newTools.push({ type: "create_order", ok: true, order_id: order?.id || null }); - } else if (act.type === "update_order") { - const order = await updateOrder({ - tenantId, - wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null, - basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] }, - address: reducedContext?.delivery_address || reducedContext?.address || null, - run_id: null, - }); - actionPatch.woo_order_id = order?.id || null; - actionPatch.order_total = calcOrderTotal(order); - newTools.push({ type: "update_order", ok: true, order_id: order?.id || null }); - } else if (act.type === "send_payment_link") { - const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null; - if (!total || total <= 0) { - throw new Error("order_total_missing"); - } - const pref = await createPreference({ - tenantId, - wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id, - amount: total || 0, - }); - actionPatch.payment_link = pref?.init_point || null; - actionPatch.mp = { - preference_id: pref?.preference_id || null, - init_point: pref?.init_point || null, - }; - newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null }); - if (pref?.init_point) { - plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`; - } - } - } catch (e) { - newTools.push({ type: act.type, ok: false, error: String(e?.message || e) }); - } - } - - tools = newTools; - } - - const run_id = await insertRun({ - tenant_id: tenantId, - wa_chat_id: chat_id, - message_id: `${provider}:${message_id}`, - prev_state, - user_text: text, - llm_output: { ...plan, _llm: llmMeta }, - tools, - invariants, - final_reply: plan.reply, - status: runStatus, - latency_ms: null, // se actualiza al final con end-to-end real - }); - mark("after_insertRun"); - - const outMessageId = newId("out"); - await insertMessage({ - tenant_id: tenantId, - wa_chat_id: chat_id, - provider, - message_id: outMessageId, - direction: "out", - text: plan.reply, - payload: { reply: plan.reply, railguard: { simulated: isSimulated, source: meta?.source || null } }, - run_id, - }); - mark("after_insertMessage_out"); - - // Si hubo error al llamar al LLM (o no hay API key), guardamos una burbuja de error clickeable. - if (llmMeta?.error) { - const errMsgId = newId("err"); - await insertMessage({ - tenant_id: tenantId, - wa_chat_id: chat_id, - provider: "system", - message_id: errMsgId, - direction: "out", - text: `[ERROR] openai: ${llmMeta.error}`, - payload: { error: { source: "openai", ...llmMeta }, input: llmInput, railguard: { simulated: isSimulated, source: meta?.source || null } }, - run_id, - }); - } - - // Woo Customer: NO lo hacemos en flujos que no lo necesitan (precio/clarificación), y nunca rompemos la conversación por un fallo de Woo. - // Lo pedimos solo cuando realmente vamos a operar con pedidos (más adelante: create/update/checkout). - const shouldEnsureWooCustomer = - classification.kind === "order" || - ["create_order", "add_item", "remove_item", "checkout"].includes(plan.intent) || - ["create", "update", "checkout"].includes(plan.order_action); - - let wooCustomerError = null; - if (shouldEnsureWooCustomer) { - mark("before_ensureWooCustomer"); - try { - if (externalCustomerId) { - const found = await getWooCustomerById({ tenantId, id: externalCustomerId }); - if (!found) { - const phone = chat_id.replace(/@.+$/, ""); - const name = displayName || from || phone; - const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); - console.log("created", created); - if (!created?.id) throw new Error("woo_customer_id_missing"); - externalCustomerId = await upsertExternalCustomerMap({ - tenant_id: tenantId, - wa_chat_id: chat_id, - external_customer_id: created?.id, - provider: "woo", - }); - } else { - // Asegurar que el mapping exista/esté actualizado - externalCustomerId = await upsertExternalCustomerMap({ - tenant_id: tenantId, - wa_chat_id: chat_id, - external_customer_id: externalCustomerId, - provider: "woo", - }); - } - } else { - const phone = chat_id.replace(/@.+$/, ""); - const name = displayName || from || phone; - const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); - console.log("created", created); - if (!created?.id) throw new Error("woo_customer_id_missing"); - externalCustomerId = await upsertExternalCustomerMap({ - tenant_id: tenantId, - wa_chat_id: chat_id, - external_customer_id: created?.id, - provider: "woo", - }); - } - } catch (e) { - wooCustomerError = { - message: String(e?.message || e), - status: e?.status || e?.cause?.status || null, - code: e?.body?.code || e?.cause?.body?.code || null, - }; - - // Burbuja roja, sin cortar el flujo. - const errMsgId = newId("wooerr"); - await insertMessage({ - tenant_id: tenantId, - wa_chat_id: chat_id, - provider: "system", - message_id: errMsgId, - direction: "out", - text: `[ERROR] woo: ${wooCustomerError.message}`, - payload: { error: { source: "woo", ...wooCustomerError } }, - run_id, - }); - - console.warn("[woo] ensure customer failed", wooCustomerError); - } finally { - mark("after_ensureWooCustomer"); - } - } - - const context = { - ...(reducedContext || {}), - ...(decision?.context_patch || {}), - ...(actionPatch || {}), - missing_fields: plan.missing_fields || [], - basket_resolved: (reducedContext?.order_basket?.items?.length ? reducedContext.order_basket : plan.basket_resolved) || { items: [] }, - external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, - railguard: { simulated: isSimulated, source: meta?.source || null }, - woo_customer_error: wooCustomerError, - }; - - const nextState = useTurnV3 - ? safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state - : plan.next_state; - if (useTurnV3) plan.next_state = nextState; - - const stateRow = await upsertConversationState({ - tenant_id: tenantId, - wa_chat_id: chat_id, - state: nextState, - last_intent: plan.intent, - last_order_id: null, - context, - }); - mark("after_upsertConversationState"); - - sseSend("conversation.upsert", { - chat_id: stateRow.wa_chat_id, - from: stateRow.wa_chat_id.replace(/^sim:/, ""), - state: stateRow.state, - intent: stateRow.last_intent || "other", - status: runStatus, - last_activity: stateRow.updated_at, - last_run_id: run_id, - }); - - const end_to_end_ms = Date.now() - started_at; - if (run_id) { - await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms }); - } - - sseSend("run.created", { - run_id, - ts: nowIso(), - chat_id, - from, - status: runStatus, - prev_state, - input: { text }, - llm_output: { ...plan, _llm: llmMeta }, - tools: [], - invariants, - final_reply: plan.reply, - order_id: null, - payment_link: null, - latency_ms: end_to_end_ms, - }); - - // Log de performance end-to-end + tramos principales (para diagnosticar "dónde se va el tiempo") - console.log("[perf] processMessage", { - tenantId, - chat_id, - provider, - message_id, - run_id, - end_to_end_ms, - ms: { - db_state_ms: msBetween("start", "after_getConversationState"), - db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"), - insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"), - history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"), - product_lookup_ms: msBetween("before_product_lookup", "after_product_lookup"), - extract_ms: msBetween("before_llmExtract", "after_llmExtract"), - resolve_items_ms: msBetween("before_resolve_items", "after_resolve_items"), - classify_ms: msBetween("before_classifyIntent", "after_classifyIntent"), - business_ms: msBetween("before_applyBusinessLogic", "after_applyBusinessLogic"), - reduce_context_ms: msBetween("before_reduceContext", "after_reduceContext"), - llm_ms: msBetween("before_llmPlan", "after_llmPlan"), - insert_run_ms: msBetween("before_insertRun", "after_insertRun"), - insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"), - woo_customer_ms: msBetween("before_ensureWooCustomer", "after_ensureWooCustomer"), - upsert_state_ms: msBetween("after_ensureWooCustomer", "after_upsertConversationState"), - }, - }); - - return { run_id, reply: plan.reply }; -} - -export function collapseAssistantMessages(messages) { - const out = []; - for (const m of messages) { - const last = out[out.length - 1]; - if (last && last.role === "assistant" && m.role === "assistant") continue; - out.push(m); - } - return out; -} - -import { ensureTenant, getTenantByKey, getTenantIdByChannel } from "../db/repo.js"; - -function parseTenantFromChatId(chat_id) { - // soporta "piaf:sim:+54..." o "piaf:+54..." etc. - const m = /^([a-z0-9_-]+):/.exec(chat_id); - return m?.[1]?.toLowerCase() || null; -} - -export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) { - // Normalizar key a lowercase para evitar duplicados por casing - const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase(); - - // 1) si viene explícito (simulador / webhook) - if (explicit) { - const t = await getTenantByKey(explicit); - if (t) return t.id; - throw new Error(`tenant_not_found: ${explicit}`); - } - - // 2) si viene el número receptor / channel key (producción) - if (to_phone) { - const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone }); - if (id) return id; - } - - // 3) fallback: env TENANT_KEY - const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase(); - const t = await getTenantByKey(fallbackKey); - if (t) return t.id; - throw new Error(`tenant_not_found: ${fallbackKey}`); -} \ No newline at end of file diff --git a/src/services/turnEngineV2.js b/src/services/turnEngineV2.js deleted file mode 100644 index 86fd33d..0000000 --- a/src/services/turnEngineV2.js +++ /dev/null @@ -1,415 +0,0 @@ -import { z } from "zod"; -import { llmNlu } from "./openai.js"; -import { searchProducts } from "./wooProducts.js"; - -// --- Types / Contracts (runtime-validated where it matters) --- - -const TurnActionSchema = z.object({ - type: z.enum(["show_options", "quote_price", "add_to_cart", "ask_clarification"]), - payload: z.record(z.any()).default({}), -}); - -function normalizeUnit(unit) { - const u = unit == null ? null : String(unit).toLowerCase(); - if (!u) return null; - 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"; - if (u === "docena" || u === "docenas") return "docena"; - return null; -} - -function pickProductQuery({ nlu, prevContext }) { - const q = nlu?.entities?.product_query || null; - if (q && String(q).trim()) return String(q).trim(); - const last = prevContext?.slots?.product_label || prevContext?.pending_item?.name || null; - return last && String(last).trim() ? String(last).trim() : null; -} - -function mapIntentToLegacy(intent) { - switch (intent) { - case "price_query": - return "ask_price"; - case "browse": - return "browse_products"; - case "add_to_cart": - return "add_item"; - case "remove_from_cart": - return "remove_item"; - case "checkout": - return "checkout"; - case "greeting": - return "other"; - default: - return "other"; - } -} - -function formatARS(n) { - const num = Number(n); - if (!Number.isFinite(num)) return String(n); - // simple ARS formatting (miles con punto) - return Math.round(num).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "."); -} - -function inferUnitHintByName(name) { - const n = String(name || "").toLowerCase(); - // Embutidos / parrilleros: puede ser kg o unidad - if (/\b(chorizo|chorizos|morcilla|morcillas|salchicha|salchichas|parrillera|parrilleras)\b/.test(n)) { - return { defaultUnit: "unit", ask: "¿Los querés por kg o por unidad?" }; - } - // Bebidas: unidad - if (/\b(vino|vinos|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/.test(n)) { - return { defaultUnit: "unit", ask: "¿Cuántas unidades querés?" }; - } - // Carnes: kg (no preguntar “por kilo”, preguntar cantidad) - return { defaultUnit: "kg", ask: "¿Cuántos kilos querés?" }; -} - -function unitAskFor(unit) { - if (unit === "g") return "¿Cuántos gramos querés?"; - if (unit === "unit") return "¿Cuántas unidades querés?"; - return "¿Cuántos kilos querés?"; -} - -function formatQty({ quantity, unit }) { - const q = Number(quantity); - if (!Number.isFinite(q) || q <= 0) return String(quantity); - if (unit === "g") return `${q}g`; - if (unit === "unit") return `${q}u`; - return `${q}kg`; -} - -function buildOptionsList(candidates, { baseIdx = 1, pageSize = 9 } = {}) { - const slice = candidates.slice(0, Math.max(1, Math.min(20, pageSize))); - const options = slice.map((c, i) => ({ - idx: baseIdx + i, - woo_product_id: c.woo_product_id, - name: c.name, - price: c.price ?? null, - })); - const list = options.map((o) => `- ${o.idx}) ${o.name}`).join("\n"); - return { options, text: `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.` }; -} - -function parseIndexSelection(text) { - const t = String(text || "").toLowerCase(); - const m = /\b(\d{1,2})\b/.exec(t); - if (!m) return null; - const n = parseInt(m[1], 10); - return Number.isFinite(n) ? n : null; -} - -/** - * Turn Engine v2: “LLM-first NLU, deterministic core” - * - * Devuelve un objeto compatible con el pipeline actual: - * { plan, llmMeta, decision } - * - plan: { reply, next_state, intent, missing_fields, order_action, basket_resolved } - * - decision.context_patch: para que pipeline lo persista - */ -export async function runTurnV2({ - tenantId, - chat_id, - text, - prev_state, - prev_context, - conversation_history, -} = {}) { - const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; - - const last_shown_options = Array.isArray(prev?.pending_clarification?.options) - ? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null })) - : []; - - const nluInput = { - last_user_message: text, - conversation_state: prev_state || "IDLE", - memory_summary: Array.isArray(conversation_history) - ? conversation_history - .slice(-6) - .map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`) - .join(" | ") - : "", - pending_context: { - pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length), - pending_item: prev?.pending_item?.name || null, - }, - last_shown_options, - locale: "es-AR", - customer_profile: prev?.customer_profile || null, - feature_flags: { turn_engine: "v2" }, - }; - - const { nlu, raw_text, model, usage } = await llmNlu({ input: nluInput }); - const llmMeta = { model, usage, raw_text, kind: "nlu_v2" }; - - const actions = []; - const context_patch = {}; - - // --- AWAITING_QUANTITY / pending_item --- - // Si ya hay un producto elegido, el siguiente turno suele ser cantidad/unidad. - if (prev?.pending_item?.product_id && !prev?.pending_clarification?.candidates?.length) { - const q0 = nlu?.entities?.quantity; - const u0 = normalizeUnit(nlu?.entities?.unit); - // Permitir "por kg"/"por unidad" sin número: actualizamos default_unit y preguntamos cantidad. - if (!q0 && u0) { - context_patch.pending_item = { ...(prev.pending_item || {}), default_unit: u0 === "g" ? "g" : u0 === "unit" ? "unit" : "kg" }; - const plan = { - reply: unitAskFor(context_patch.pending_item.default_unit), - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: ["quantity"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_set_unit" } }; - return { plan, llmMeta, decision }; - } - - // Número solo (ej "4") -> quantity, con unit por default_unit del pending_item. - if (q0 && Number(q0) > 0) { - const defaultUnit = prev.pending_item.default_unit || "kg"; - const unit = u0 === "g" ? "g" : u0 === "unit" || u0 === "docena" ? "unit" : defaultUnit === "g" ? "g" : defaultUnit === "unit" ? "unit" : "kg"; - const qty = u0 === "docena" ? Number(q0) * 12 : Number(q0); - const it = { - product_id: Number(prev.pending_item.product_id), - variation_id: prev.pending_item.variation_id ?? null, - quantity: qty, - unit, - label: prev.pending_item.name || "ese producto", - }; - context_patch.pending_item = null; - context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] }; - actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } }); - const plan = { - reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${it.label}. ¿Algo más?`, - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [it] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_qty" } }; - return { plan, llmMeta, decision }; - } - - // Si no entendimos cantidad, re-preguntamos con la unidad correcta. - const fallbackUnit = prev.pending_item.default_unit || inferUnitHintByName(prev.pending_item.name || "").defaultUnit; - const plan = { - reply: unitAskFor(fallbackUnit === "unit" ? "unit" : fallbackUnit === "g" ? "g" : "kg"), - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: ["quantity"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "pending_item_reask" } }; - return { plan, llmMeta, decision }; - } - - // --- Deterministic overrides from state/pending --- - // Si hay pending_clarification, una respuesta numérica debe interpretarse como selección. - const selectionIdx = - (nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ?? - (prev?.pending_clarification?.candidates?.length ? parseIndexSelection(text) : null); - - // Si hay selectionIdx y options vigentes, elegimos producto. - let chosen = null; - if (selectionIdx && Array.isArray(prev?.pending_clarification?.options)) { - const opt = prev.pending_clarification.options.find((o) => o.idx === selectionIdx && o.type === "product"); - if (opt) { - chosen = prev.pending_clarification.candidates?.find((c) => c.woo_product_id === opt.woo_product_id) || null; - } - } - - // --- Retrieval (catálogo) --- - const productQuery = pickProductQuery({ nlu, prevContext: prev }); - let candidates = []; - if (nlu?.needs?.catalog_lookup || ["price_query", "browse", "add_to_cart"].includes(nlu.intent)) { - if (productQuery) { - const { items } = await searchProducts({ tenantId, q: productQuery, limit: 12 }); - candidates = Array.isArray(items) ? items : []; - } - } - - // Si no venía de pending_clarification pero NLU pidió browse, y tenemos candidatos, mostramos lista. - if (!chosen && (nlu.intent === "browse" || (nlu.intent === "price_query" && candidates.length > 1))) { - const { options, text: listText } = buildOptionsList(candidates, { baseIdx: 1, pageSize: 9 }); - actions.push({ type: "show_options", payload: { count: options.length } }); - context_patch.pending_clarification = { - candidates: candidates.map((c) => ({ - woo_product_id: c.woo_product_id, - name: c.name, - price: c.price ?? null, - categories: c.categories || [], - attributes: c.attributes || [], - })), - options: options.map((o) => ({ idx: o.idx, type: "product", woo_product_id: o.woo_product_id, name: o.name })), - candidate_offset: 0, - page_size: 9, - base_idx: 1, - has_more: false, - next_candidate_offset: options.length, - next_base_idx: options.length + 1, - }; - // clave: al entrar en selección por lista, anulamos pending_item viejo - context_patch.pending_item = null; - const plan = { - reply: listText, - next_state: "BROWSING", - intent: "browse_products", - missing_fields: ["product_selection"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } }; - return { plan, llmMeta, decision }; - } - - // Si hubo elección por índice (chosen), pasamos a AWAITING_QUANTITY (representado como BUILDING_ORDER). - if (chosen) { - actions.push({ type: "ask_clarification", payload: { reason: "missing_quantity" } }); - const unitHint = inferUnitHintByName(chosen.name); - context_patch.pending_clarification = null; - context_patch.pending_item = { - product_id: Number(chosen.woo_product_id), - variation_id: null, - name: chosen.name, - price: chosen.price ?? null, - categories: chosen.categories || [], - attributes: chosen.attributes || [], - default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg", - }; - - // si el mismo turno ya trajo cantidad, agregamos al carrito directo - const q = nlu?.entities?.quantity; - const u = normalizeUnit(nlu?.entities?.unit); - if (q && Number(q) > 0) { - const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : "kg"; - const qty = u === "docena" ? Number(q) * 12 : Number(q); - const it = { - product_id: Number(chosen.woo_product_id), - variation_id: null, - quantity: qty, - unit, - label: chosen.name, - }; - context_patch.pending_item = null; - context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] }; - actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } }); - const plan = { - reply: `Perfecto, anoto ${unit === "kg" ? `${qty}kg` : `${qty}u`} de ${chosen.name}. ¿Algo más?`, - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [it] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } }; - return { plan, llmMeta, decision }; - } - - const plan = { - reply: unitHint.ask, - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: ["quantity"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, chosen: { id: chosen.woo_product_id, name: chosen.name } } }; - return { plan, llmMeta, decision }; - } - - // price_query: si hay 1 candidato claro => cotizar - if (nlu.intent === "price_query" && candidates.length === 1) { - const c = candidates[0]; - actions.push({ type: "quote_price", payload: { product_id: c.woo_product_id } }); - const unitHint = inferUnitHintByName(c.name); - const price = c.price; - const per = unitHint.defaultUnit === "unit" ? "por unidad" : "el kilo"; - const reply = - price != null - ? `${c.name} está $${formatARS(price)} ${per}. ${unitHint.ask}` - : `Dale, lo reviso y te confirmo el precio en un momento. ${unitHint.ask}`; - // en price, dejamos pending_item para que el siguiente turno sea cantidad - context_patch.pending_item = { - product_id: Number(c.woo_product_id), - variation_id: null, - name: c.name, - price: c.price ?? null, - categories: c.categories || [], - attributes: c.attributes || [], - default_unit: unitHint.defaultUnit === "unit" ? "unit" : "kg", - }; - context_patch.pending_clarification = null; - const plan = { - reply, - next_state: "BROWSING", - intent: "ask_price", - missing_fields: ["quantity"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, candidates_count: candidates.length } }; - return { plan, llmMeta, decision }; - } - - // add_to_cart sin candidates: preguntamos producto - if (nlu.intent === "add_to_cart" && !productQuery) { - actions.push({ type: "ask_clarification", payload: { reason: "missing_product" } }); - const plan = { - reply: "Dale. ¿Qué producto querés agregar?", - next_state: prev_state || "IDLE", - intent: "add_item", - missing_fields: ["product_query"], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu } }; - return { plan, llmMeta, decision }; - } - - // add_to_cart con candidates y cantidad: si hay 1 candidato claro, agregamos directo. - if (nlu.intent === "add_to_cart" && candidates.length === 1 && nlu?.entities?.quantity && Number(nlu.entities.quantity) > 0) { - const c = candidates[0]; - const u = normalizeUnit(nlu?.entities?.unit); - const unitHint = inferUnitHintByName(c.name); - const unit = u === "g" ? "g" : u === "unit" || u === "docena" ? "unit" : unitHint.defaultUnit === "unit" ? "unit" : "kg"; - const qty = u === "docena" ? Number(nlu.entities.quantity) * 12 : Number(nlu.entities.quantity); - const it = { product_id: Number(c.woo_product_id), variation_id: null, quantity: qty, unit, label: c.name }; - context_patch.order_basket = { items: [...(prev?.order_basket?.items || []), it] }; - actions.push({ type: "add_to_cart", payload: { product_id: it.product_id, quantity: it.quantity, unit: it.unit } }); - const plan = { - reply: `Perfecto, anoto ${formatQty({ quantity: it.quantity, unit: it.unit })} de ${c.name}. ¿Algo más?`, - next_state: "BUILDING_ORDER", - intent: "add_item", - missing_fields: [], - order_action: "none", - basket_resolved: { items: [it] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, stage: "add_to_cart_direct" } }; - return { plan, llmMeta, decision }; - } - - // Fallback: respuesta segura y corta, sin inventar. - const legacyIntent = mapIntentToLegacy(nlu.intent); - const plan = { - reply: nlu?.clarification?.question || "Dale. ¿Qué necesitás exactamente?", - next_state: prev_state || "IDLE", - intent: legacyIntent, - missing_fields: [], - order_action: "none", - basket_resolved: { items: [] }, - }; - const decision = { mode: "turn_v2", context_patch, actions, audit: { nlu, productQuery, candidates_count: candidates.length } }; - // validate actions shape (best-effort, no throw) - try { - decision.actions = Array.isArray(actions) ? actions.map((a) => TurnActionSchema.parse(a)) : []; - } catch { - decision.actions = []; - } - return { plan, llmMeta, decision }; -} - diff --git a/src/services/wooProducts.js b/src/services/wooProducts.js deleted file mode 100644 index 07aeb21..0000000 --- a/src/services/wooProducts.js +++ /dev/null @@ -1,277 +0,0 @@ -import { getDecryptedTenantEcommerceConfig, getWooProductCacheById, searchWooProductCache, upsertWooProductCache } from "../db/repo.js"; -import { debug as dbg } from "./debug.js"; - -async function fetchWoo({ url, method = "GET", body = null, timeout = 8000, headers = {} }) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout); - const t0 = Date.now(); - try { - const res = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - const text = await res.text(); - let parsed; - try { - parsed = text ? JSON.parse(text) : null; - } catch { - parsed = text; - } - if (!res.ok) { - const err = new Error(`Woo HTTP ${res.status}`); - err.status = res.status; - err.body = parsed; - err.url = url; - err.method = method; - throw err; - } - return parsed; - } catch (e) { - const err = new Error(`Woo request failed after ${Date.now() - t0}ms: ${e.message}`); - err.cause = e; - throw err; - } finally { - clearTimeout(timer); - } -} - -async function getWooClient({ tenantId }) { - const encryptionKey = process.env.APP_ENCRYPTION_KEY; - if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials"); - - const cfg = await getDecryptedTenantEcommerceConfig({ - tenant_id: tenantId, - provider: "woo", - encryption_key: encryptionKey, - }); - if (!cfg) throw new Error("Woo config not found for tenant"); - - const consumerKey = - cfg.consumer_key || - process.env.WOO_CONSUMER_KEY || - (() => { - throw new Error("consumer_key not set"); - })(); - const consumerSecret = - cfg.consumer_secret || - process.env.WOO_CONSUMER_SECRET || - (() => { - throw new Error("consumer_secret not set"); - })(); - - const base = cfg.base_url.replace(/\/+$/, ""); - const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64"); - - return { - base, - authHeader: { Authorization: `Basic ${auth}` }, - timeout: Math.max(cfg.timeout_ms ?? 8000, 2000), - }; -} - -function parsePrice(p) { - if (p == null) return null; - const n = Number(String(p).replace(",", ".")); - return Number.isFinite(n) ? n : null; -} - -function isStale(refreshed_at, maxAgeMs) { - if (!refreshed_at) return true; - const t = new Date(refreshed_at).getTime(); - if (!Number.isFinite(t)) return true; - return Date.now() - t > maxAgeMs; -} - -function normalizeWooProduct(p) { - return { - woo_product_id: p?.id, - name: p?.name || "", - sku: p?.sku || null, - price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price), - currency: null, - type: p?.type || null, // simple | variable | grouped | external - categories: Array.isArray(p?.categories) - ? p.categories.map((c) => ({ - id: c?.id ?? null, - name: c?.name ?? null, - slug: c?.slug ?? null, - })) - : [], - attributes: Array.isArray(p?.attributes) - ? p.attributes.map((a) => ({ - name: a?.name || null, - options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [], - })) - : [], - raw_price: { - price: p?.price ?? null, - regular_price: p?.regular_price ?? null, - sale_price: p?.sale_price ?? null, - price_html: p?.price_html ?? null, - }, - payload: p, - }; -} - -export async function searchProducts({ - tenantId, - q, - limit = 10, - maxAgeMs = 24 * 60 * 60 * 1000, - forceWoo = false, -}) { - const debug = dbg.wooProducts; - const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10)); - const query = String(q || "").trim(); - if (!query) return { items: [], source: "none" }; - - // 1) Cache en Postgres - const cached = await searchWooProductCache({ tenant_id: tenantId, q: query, limit: lim }); - - // 2) Si no hay suficiente (o force), buscamos en Woo y cacheamos - // Nota: si el cache tiene 3 items pero pedimos 12, igual necesitamos ir a Woo para no “recortar” catálogo. - const needWooSearch = forceWoo || cached.length < lim; - const client = await getWooClient({ tenantId }); - - let wooItems = []; - if (needWooSearch) { - const url = `${client.base}/products?search=${encodeURIComponent(query)}&per_page=${lim}`; - const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); - wooItems = Array.isArray(data) ? data : []; - - if (debug) { - console.log("[wooProducts] search", { - tenantId, - query, - count: wooItems.length, - sample: wooItems.slice(0, 5).map((p) => ({ - id: p?.id, - name: p?.name, - sku: p?.sku, - price: p?.price, - regular_price: p?.regular_price, - sale_price: p?.sale_price, - })), - }); - } - - for (const p of wooItems) { - const n = normalizeWooProduct(p); - if (!n.woo_product_id || !n.name) continue; - await upsertWooProductCache({ - tenant_id: tenantId, - woo_product_id: n.woo_product_id, - name: n.name, - sku: n.sku, - price: n.price, - currency: n.currency, - payload: n.payload, - refreshed_at: new Date().toISOString(), - }); - } - } - - // 3) Si tenemos cache pero está stale, refrescamos precio contra Woo (por ID) y actualizamos si cambió. - // Nota: lo hacemos solo para los items que vamos a devolver (lim), para no demorar demasiado. - const toReturn = cached.slice(0, lim); - for (const c of toReturn) { - if (!isStale(c.refreshed_at, maxAgeMs)) continue; - try { - const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`; - const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); - if (debug) { - console.log("[wooProducts] refresh", { - tenantId, - woo_product_id: c.woo_product_id, - name: p?.name, - sku: p?.sku, - price: p?.price, - regular_price: p?.regular_price, - sale_price: p?.sale_price, - }); - } - const n = normalizeWooProduct(p); - if (!n.woo_product_id || !n.name) continue; - // Si cambió el precio (o faltaba), actualizamos. - const prevPrice = c.price == null ? null : Number(c.price); - const nextPrice = n.price; - const changed = prevPrice !== nextPrice; - - await upsertWooProductCache({ - tenant_id: tenantId, - woo_product_id: n.woo_product_id, - name: n.name, - sku: n.sku, - price: n.price, - currency: n.currency, - payload: n.payload, - refreshed_at: new Date().toISOString(), - }); - - if (changed) { - // mantener coherencia de respuesta: refrescar item desde DB - const refreshed = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: n.woo_product_id }); - if (refreshed) Object.assign(c, refreshed); - } - } catch { - // si Woo falla, devolvemos cache (mejor que nada) - } - } - - // 4) Respuesta “unificada” (preferimos cache, pero si hicimos Woo search devolvemos esos) - const finalItems = needWooSearch - ? wooItems - .map(normalizeWooProduct) - .filter((p) => p.woo_product_id && p.name) - .slice(0, lim) - .map((p) => ({ - woo_product_id: p.woo_product_id, - name: p.name, - sku: p.sku, - price: p.price, - currency: p.currency, - type: p.type, - categories: p.categories, - attributes: p.attributes, - raw_price: p.raw_price, - source: "woo", - })) - : toReturn.map((c) => ({ - woo_product_id: c.woo_product_id, - name: c.name, - sku: c.sku, - price: c.price == null ? null : Number(c.price), - currency: c.currency, - refreshed_at: c.refreshed_at, - type: c?.payload?.type || null, - categories: Array.isArray(c?.payload?.categories) - ? c.payload.categories.map((cat) => ({ - id: cat?.id ?? null, - name: cat?.name ?? null, - slug: cat?.slug ?? null, - })) - : [], - attributes: Array.isArray(c?.payload?.attributes) - ? c.payload.attributes.map((a) => ({ - name: a?.name || null, - options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [], - })) - : [], - raw_price: { - price: c?.payload?.price ?? null, - regular_price: c?.payload?.regular_price ?? null, - sale_price: c?.payload?.sale_price ?? null, - price_html: c?.payload?.price_html ?? null, - }, - source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache", - })); - - return { items: finalItems, source: needWooSearch ? "woo" : "cache" }; -} - -