separated in modules

This commit is contained in:
Lucas Tettamanti
2026-01-15 22:45:33 -03:00
parent eedd16afdb
commit ea62385e3d
41 changed files with 1116 additions and 2918 deletions

View File

@@ -1,8 +1,8 @@
# TODOs # 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`. - 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. - (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. - 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. - 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.

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -1,5 +1,5 @@
import "dotenv/config"; 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"; import { createApp } from "./src/app.js";
async function configureUndiciDispatcher() { async function configureUndiciDispatcher() {

7
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"openai": "^6.15.0", "openai": "^6.15.0",
@@ -357,6 +358,12 @@
"node": ">= 0.10" "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": { "node_modules/dbmate": {
"version": "2.28.0", "version": "2.28.0",
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz", "resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",

View File

@@ -18,6 +18,7 @@
"dependencies": { "dependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"openai": "^6.15.0", "openai": "^6.15.0",

View File

@@ -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 <path> [--tenant-key <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);
});

View File

@@ -3,15 +3,16 @@ import cors from "cors";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { createSimulatorRouter } from "./routes/simulator.js"; import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
import { createEvolutionRouter } from "./routes/evolution.js"; import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
import { createMercadoPagoRouter } from "./routes/mercadoPago.js"; import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js";
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
export function createApp({ tenantId }) { export function createApp({ tenantId }) {
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
// Serve /public as static (UI + webcomponents) // Serve /public as static (UI + webcomponents)
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -23,6 +24,7 @@ export function createApp({ tenantId }) {
app.use(createSimulatorRouter({ tenantId })); app.use(createSimulatorRouter({ tenantId }));
app.use(createEvolutionRouter()); app.use(createEvolutionRouter());
app.use("/payments/meli", createMercadoPagoRouter()); app.use("/payments/meli", createMercadoPagoRouter());
app.use(createWooWebhooksRouter());
// Home (UI) // Home (UI)
app.get("/", (req, res) => { app.get("/", (req, res) => {

View File

@@ -6,9 +6,9 @@ import {
getIdentityMapByChat, getIdentityMapByChat,
getLastInboundMessage, getLastInboundMessage,
listUsers, listUsers,
} from "../db/repo.js"; } from "../modules/2-identity/db/repo.js";
import { deleteWooCustomer } from "../services/woo.js"; import { deleteWooCustomer } from "../modules/2-identity/services/woo.js";
import { processMessage } from "../services/pipeline.js"; import { processMessage } from "../modules/2-identity/services/pipeline.js";
export async function handleDeleteConversation({ tenantId, chat_id }) { export async function handleDeleteConversation({ tenantId, chat_id }) {
if (!chat_id) return { ok: false, error: "chat_id_required" }; if (!chat_id) return { ok: false, error: "chat_id_required" };

View File

@@ -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 }) { export async function handleGetConversationState({ tenantId, chat_id }) {
if (!chat_id) { if (!chat_id) {

View File

@@ -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 }) { export async function handleListConversations({ tenantId, query }) {
const { q = "", status = "", state = "", limit = "50" } = query || {}; const { q = "", status = "", state = "", limit = "50" } = query || {};

View File

@@ -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" }) { export async function handleListMessages({ tenantId, chat_id, limit = "200" }) {
if (!chat_id) return []; if (!chat_id) return [];

View File

@@ -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" }) { export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
const { items, source } = await searchProducts({ const { items, source } = await searchSnapshotItems({
tenantId, tenantId,
q, q,
limit: parseInt(limit, 10) || 10, limit: parseInt(limit, 10) || 10,
forceWoo: String(forceWoo) === "1" || String(forceWoo).toLowerCase() === "true",
}); });
return { items, source }; return { items, source };
} }

View File

@@ -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" }) { export async function handleListRuns({ tenantId, chat_id = null, limit = "50" }) {
return listRuns({ return listRuns({

View File

@@ -1,7 +1,7 @@
import crypto from "crypto"; import crypto from "crypto";
import { parseEvolutionWebhook } from "../services/evolutionParser.js"; import { parseEvolutionWebhook } from "../services/evolutionParser.js";
import { resolveTenantId, processMessage } from "../services/pipeline.js"; import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
import { debug as dbg } from "../services/debug.js"; import { debug as dbg } from "../../shared/debug.js";
export async function handleEvolutionWebhook(body) { export async function handleEvolutionWebhook(body) {
const t0 = Date.now(); const t0 = Date.now();

View File

@@ -1,6 +1,6 @@
import crypto from "crypto"; import crypto from "crypto";
import { resolveTenantId } from "../services/pipeline.js"; import { resolveTenantId } from "../../2-identity/services/pipeline.js";
import { processMessage } from "../services/pipeline.js"; import { processMessage } from "../../2-identity/services/pipeline.js";
export async function handleSimSend(body) { export async function handleSimSend(body) {
const { chat_id, from_phone, text } = body || {}; const { chat_id, from_phone, text } = body || {};

View File

@@ -1,13 +1,13 @@
import express from "express"; import express from "express";
import { addSseClient, removeSseClient } from "../services/sse.js"; import { addSseClient, removeSseClient } from "../../shared/sse.js";
import { makeGetConversations } from "../controllers/conversations.js"; import { makeGetConversations } from "../../../controllers/conversations.js";
import { makeListRuns, makeGetRunById } from "../controllers/runs.js"; import { makeListRuns, makeGetRunById } from "../../../controllers/runs.js";
import { makeSimSend } from "../controllers/sim.js"; import { makeSimSend } from "../controllers/sim.js";
import { makeGetConversationState } from "../controllers/conversationState.js"; import { makeGetConversationState } from "../../../controllers/conversationState.js";
import { makeListMessages } from "../controllers/messages.js"; import { makeListMessages } from "../../../controllers/messages.js";
import { makeSearchProducts } from "../controllers/products.js"; import { makeSearchProducts } from "../../../controllers/products.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../controllers/admin.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../../controllers/admin.js";
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();

View File

@@ -55,3 +55,4 @@ export function parseEvolutionWebhook(reqBody) {
raw: body, // para log/debug si querés raw: body, // para log/debug si querés
}; };
} }

View File

@@ -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,
});
};
}

View File

@@ -288,7 +288,7 @@ export async function getRecentMessagesForLLM({
`; `;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, lim]); 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", role: r.direction === "in" ? "user" : "assistant",
content: String(r.text).trim().slice(0, maxCharsPerMessage), content: String(r.text).trim().slice(0, maxCharsPerMessage),
})); }));
@@ -529,98 +529,6 @@ export async function getDecryptedTenantEcommerceConfig({
return rows[0] || null; 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 }) { export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20)); const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
const query = String(q || "").trim(); 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]); const { rows } = await pool.query(sql, [tenant_id, payment_id]);
return rows[0] || null; return rows[0] || null;
} }

View File

@@ -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;
}

View File

@@ -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}`);
}

View File

@@ -1,6 +1,6 @@
import crypto from "crypto"; import crypto from "crypto";
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js"; 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) --- // --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
const locks = new Map(); const locks = new Map();
@@ -42,12 +42,8 @@ function isRetryableNetworkError(err) {
const e2 = getDeep(err, ["cause", "cause"]); const e2 = getDeep(err, ["cause", "cause"]);
const candidates = [e0, e1, e2].filter(Boolean); const candidates = [e0, e1, e2].filter(Boolean);
const codes = new Set( const codes = new Set(candidates.map((e) => e.code).filter(Boolean));
candidates.map((e) => e.code).filter(Boolean) const names = new Set(candidates.map((e) => e.name).filter(Boolean));
);
const names = new Set(
candidates.map((e) => e.name).filter(Boolean)
);
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase(); const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
const aborted = const aborted =

View File

@@ -1,12 +1,12 @@
import crypto from "crypto"; import crypto from "crypto";
import OpenAI from "openai"; import OpenAI from "openai";
import { debug as dbg } from "./debug.js"; import { debug as dbg } from "../shared/debug.js";
import { searchProducts } from "./wooProducts.js"; import { searchSnapshotItems } from "../shared/wooSnapshot.js";
import { import {
searchProductAliases, searchProductAliases,
getProductEmbedding, getProductEmbedding,
upsertProductEmbedding, upsertProductEmbedding,
} from "../db/repo.js"; } from "../2-identity/db/repo.js";
function getOpenAiKey() { function getOpenAiKey() {
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null; return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
@@ -148,13 +148,12 @@ export async function retrieveCandidates({
} }
audit.sources.aliases = aliases.length; audit.sources.aliases = aliases.length;
const { items: wooItems, source: wooSource } = await searchProducts({ const { items: wooItems, source: wooSource } = await searchSnapshotItems({
tenantId, tenantId,
q, q,
limit: lim, 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) => { let candidates = (wooItems || []).map((c) => {
const lit = literalScore(q, c); const lit = literalScore(q, c);
@@ -208,3 +207,4 @@ export async function retrieveCandidates({
return { candidates: finalList, audit }; return { candidates: finalList, audit };
} }

View File

@@ -1,7 +1,6 @@
import OpenAI from "openai"; import OpenAI from "openai";
import { z } from "zod";
import Ajv from "ajv"; import Ajv from "ajv";
import { debug as dbg } from "./debug.js"; import { debug as dbg } from "../shared/debug.js";
let _client = null; let _client = null;
let _clientKey = null; let _clientKey = null;
@@ -23,67 +22,6 @@ function getClient() {
return _client; 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) { function extractJsonObject(text) {
const s = String(text || ""); const s = String(text || "");
const i = s.indexOf("{"); const i = s.indexOf("{");
@@ -262,138 +200,4 @@ export async function llmNluV3({ input, model } = {}) {
} }
} }
/** // Legacy llmPlan/llmExtract y NLU v2 removidos.
* 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 };
}

View File

@@ -582,3 +582,4 @@ export async function runTurnV3({
decision: { actions, context_patch, audit: { ...audit, fsm: v } }, decision: { actions, context_patch, audit: { ...audit, fsm: v } },
}; };
} }

View File

@@ -1,5 +1,6 @@
import { getDecryptedTenantEcommerceConfig, getWooProductCacheById } from "../db/repo.js"; import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
import { debug as dbg } from "./debug.js"; import { debug as dbg } from "../shared/debug.js";
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
// --- Simple in-memory lock to serialize work per key --- // --- Simple in-memory lock to serialize work per key ---
const locks = new Map(); const locks = new Map();
@@ -100,8 +101,8 @@ function parsePrice(p) {
async function getWooProductPrice({ tenantId, productId }) { async function getWooProductPrice({ tenantId, productId }) {
if (!productId) return null; if (!productId) return null;
const cached = await getWooProductCacheById({ tenant_id: tenantId, woo_product_id: productId }); const snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId });
if (cached?.price != null) return Number(cached.price); if (snap != null) return Number(snap);
const client = await getWooClient({ tenantId }); const client = await getWooClient({ tenantId });
const url = `${client.base}/products/${encodeURIComponent(productId)}`; const url = `${client.base}/products/${encodeURIComponent(productId)}`;
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); 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 }; return { id: data?.id || wooOrderId, raw: data };
}); });
} }

View File

@@ -1,4 +1,4 @@
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../services/mercadoPago.js"; import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
export function makeMercadoPagoWebhook() { export function makeMercadoPagoWebhook() {
return async function handleMercadoPagoWebhook(req, res) { return async function handleMercadoPagoWebhook(req, res) {
@@ -39,3 +39,4 @@ export function makeMercadoPagoReturn() {
res.status(200).send(`OK - ${status}`); res.status(200).send(`OK - ${status}`);
}; };
} }

View File

@@ -1,6 +1,6 @@
import crypto from "crypto"; import crypto from "crypto";
import { upsertMpPayment } from "../db/repo.js"; import { upsertMpPayment } from "../2-identity/db/repo.js";
import { updateOrderStatus } from "./wooOrders.js"; import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
function getAccessToken() { function getAccessToken() {
return process.env.MP_ACCESS_TOKEN || null; 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 }; return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
} }

View File

@@ -7,3 +7,4 @@ export function createMercadoPagoRouter() {
router.get("/return", makeMercadoPagoReturn()); router.get("/return", makeMercadoPagoReturn());
return router; return router;
} }

View File

@@ -13,7 +13,6 @@ function envIsOff(v) {
* *
* - DEBUG_PERF: performance/latencias * - DEBUG_PERF: performance/latencias
* - DEBUG_WOO_HTTP: requests/responses a Woo (status, timings, tamaño) * - 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_LLM: requests/responses a OpenAI
* - DEBUG_EVOLUTION: hook evolution + parse * - DEBUG_EVOLUTION: hook evolution + parse
* - DEBUG_DB: queries/latencias DB (si se instrumenta) * - DEBUG_DB: queries/latencias DB (si se instrumenta)
@@ -23,9 +22,6 @@ export const debug = {
perf: envIsOn(process.env.DEBUG_PERF), perf: envIsOn(process.env.DEBUG_PERF),
wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP), wooHttp: envIsOn(process.env.DEBUG_WOO_HTTP),
wooProducts: envIsOn(process.env.DEBUG_WOO_PRODUCTS),
llm: envIsOn(process.env.DEBUG_LLM), llm: envIsOn(process.env.DEBUG_LLM),
evolution: envIsOn(process.env.DEBUG_EVOLUTION), evolution: envIsOn(process.env.DEBUG_EVOLUTION),
@@ -39,4 +35,3 @@ export function debugOn(flagName) {
return Boolean(debug?.[flagName]); return Boolean(debug?.[flagName]);
} }

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 };
}

View File

@@ -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" };
}