-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
`;
@@ -46,10 +95,39 @@ class OpsShell extends HTMLElement {
const el = this.shadowRoot.getElementById("sseStatus");
el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)";
});
+
+ // Listen for view switch requests from other components
+ this._unsubSwitch = on("ui:switchView", ({ view }) => {
+ if (view) this.setView(view);
+ });
+
+ // Navigation
+ const navBtns = this.shadowRoot.querySelectorAll(".nav-btn");
+ for (const btn of navBtns) {
+ btn.onclick = () => this.setView(btn.dataset.view);
+ }
}
disconnectedCallback() {
this._unsub?.();
+ this._unsubSwitch?.();
+ }
+
+ setView(viewName) {
+ this._currentView = viewName;
+
+ // Update nav buttons
+ const navBtns = this.shadowRoot.querySelectorAll(".nav-btn");
+ for (const btn of navBtns) {
+ btn.classList.toggle("active", btn.dataset.view === viewName);
+ }
+
+ // Update views
+ const views = this.shadowRoot.querySelectorAll(".view");
+ for (const view of views) {
+ const isActive = view.id === `view${viewName.charAt(0).toUpperCase() + viewName.slice(1)}`;
+ view.classList.toggle("active", isActive);
+ }
}
}
diff --git a/public/components/products-crud.js b/public/components/products-crud.js
new file mode 100644
index 0000000..628c1c1
--- /dev/null
+++ b/public/components/products-crud.js
@@ -0,0 +1,267 @@
+import { api } from "../lib/api.js";
+
+class ProductsCrud extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.items = [];
+ this.selected = null;
+ this.loading = false;
+ this.searchQuery = "";
+ this.stockFilter = false;
+
+ this.shadowRoot.innerHTML = `
+
+
+
+
+
Productos
+
+
+
+
+
+
+
Cargando productos...
+
+
+
+
+
Detalle
+
+
Seleccioná un producto para ver detalles
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ this.shadowRoot.getElementById("search").oninput = (e) => {
+ this.searchQuery = e.target.value;
+ clearTimeout(this._searchTimer);
+ this._searchTimer = setTimeout(() => this.load(), 300);
+ };
+
+ this.shadowRoot.getElementById("syncBtn").onclick = () => this.syncFromWoo();
+
+ // Stats click handlers
+ this.shadowRoot.getElementById("statTotal").onclick = () => {
+ this.stockFilter = false;
+ this.renderList();
+ this.updateStatStyles();
+ };
+
+ this.shadowRoot.getElementById("statStock").onclick = () => {
+ this.stockFilter = !this.stockFilter;
+ this.renderList();
+ this.updateStatStyles();
+ };
+
+ this.load();
+ }
+
+ updateStatStyles() {
+ const statTotal = this.shadowRoot.getElementById("statTotal");
+ const statStock = this.shadowRoot.getElementById("statStock");
+ statTotal.classList.toggle("active", !this.stockFilter);
+ statStock.classList.toggle("active", this.stockFilter);
+ }
+
+ async load() {
+ this.loading = true;
+ this.renderList();
+
+ try {
+ const data = await api.products({ q: this.searchQuery, limit: 2000 });
+ this.items = data.items || [];
+ this.loading = false;
+ this.renderList();
+ this.renderStats();
+ } catch (e) {
+ console.error("Error loading products:", e);
+ this.items = [];
+ this.loading = false;
+ this.renderList();
+ }
+ }
+
+ async syncFromWoo() {
+ const btn = this.shadowRoot.getElementById("syncBtn");
+ btn.disabled = true;
+ btn.textContent = "Sincronizando...";
+
+ try {
+ await api.syncProducts();
+ await this.load();
+ } catch (e) {
+ console.error("Error syncing products:", e);
+ alert("Error sincronizando: " + (e.message || e));
+ } finally {
+ btn.disabled = false;
+ btn.textContent = "Sync Woo";
+ }
+ }
+
+ renderStats() {
+ const total = this.items.length;
+ const inStock = this.items.filter(p => p.stock_status === "instock" || p.payload?.stock_status === "instock").length;
+
+ this.shadowRoot.getElementById("totalCount").textContent = total;
+ this.shadowRoot.getElementById("inStockCount").textContent = inStock;
+ }
+
+ renderList() {
+ const list = this.shadowRoot.getElementById("list");
+
+ if (this.loading) {
+ list.innerHTML = `
Cargando productos...
`;
+ return;
+ }
+
+ // Filter items based on stock filter
+ const filteredItems = this.stockFilter
+ ? this.items.filter(p => p.stock_status === "instock" || p.payload?.stock_status === "instock")
+ : this.items;
+
+ if (!filteredItems.length) {
+ list.innerHTML = `
No se encontraron productos
`;
+ return;
+ }
+
+ list.innerHTML = "";
+ for (const item of filteredItems) {
+ const el = document.createElement("div");
+ el.className = "item" + (this.selected?.woo_product_id === item.woo_product_id ? " active" : "");
+
+ const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
+ const sku = item.sku || "—";
+ const stock = item.stock_status || item.payload?.stock_status || "unknown";
+ const stockBadge = stock === "instock"
+ ? `
En stock`
+ : `
Sin stock`;
+
+ el.innerHTML = `
+
${item.name || "Sin nombre"} ${stockBadge}
+
+ ${price} ·
+ SKU: ${sku} ·
+ ID: ${item.woo_product_id}
+
+ `;
+
+ el.onclick = () => {
+ this.selected = item;
+ this.renderList();
+ this.renderDetail();
+ // Scroll detail panel to top
+ const detail = this.shadowRoot.getElementById("detail");
+ if (detail) detail.scrollTop = 0;
+ };
+
+ list.appendChild(el);
+ }
+ }
+
+ renderDetail() {
+ const detail = this.shadowRoot.getElementById("detail");
+
+ if (!this.selected) {
+ detail.innerHTML = `
Seleccioná un producto para ver detalles
`;
+ return;
+ }
+
+ const p = this.selected;
+ const categories = (p.payload?.categories || []).map(c => c.name).join(", ") || "—";
+ const attributes = (p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
+
+ detail.innerHTML = `
+
+
+
${p.name || "—"}
+
+
+
+
${p.woo_product_id}
+
+
+
+
${p.sku || "—"}
+
+
+
+
${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}
+
+
+
+
${categories}
+
+
+
+
${attributes}
+
+
+
+
${p.refreshed_at ? new Date(p.refreshed_at).toLocaleString() : "—"}
+
+
+
+
${JSON.stringify(p.payload || {}, null, 2)}
+
+ `;
+ }
+}
+
+customElements.define("products-crud", ProductsCrud);
diff --git a/public/components/recommendations-crud.js b/public/components/recommendations-crud.js
new file mode 100644
index 0000000..917966a
--- /dev/null
+++ b/public/components/recommendations-crud.js
@@ -0,0 +1,320 @@
+import { api } from "../lib/api.js";
+
+class RecommendationsCrud extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.items = [];
+ this.selected = null;
+ this.loading = false;
+ this.searchQuery = "";
+ this.editMode = null; // 'create' | 'edit' | null
+
+ this.shadowRoot.innerHTML = `
+
+
+
+
+
Reglas de Recomendacion
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ this.shadowRoot.getElementById("search").oninput = (e) => {
+ this.searchQuery = e.target.value;
+ clearTimeout(this._searchTimer);
+ this._searchTimer = setTimeout(() => this.load(), 300);
+ };
+
+ this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm();
+
+ this.load();
+ }
+
+ async load() {
+ this.loading = true;
+ this.renderList();
+
+ try {
+ const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
+ this.items = data.items || [];
+ this.loading = false;
+ this.renderList();
+ } catch (e) {
+ console.error("Error loading recommendations:", e);
+ this.items = [];
+ this.loading = false;
+ this.renderList();
+ }
+ }
+
+ renderList() {
+ const list = this.shadowRoot.getElementById("list");
+
+ if (this.loading) {
+ list.innerHTML = `
Cargando...
`;
+ return;
+ }
+
+ if (!this.items.length) {
+ list.innerHTML = `
No se encontraron reglas
`;
+ return;
+ }
+
+ list.innerHTML = "";
+ for (const item of this.items) {
+ const el = document.createElement("div");
+ el.className = "item" + (this.selected?.id === item.id ? " active" : "");
+
+ const trigger = item.trigger || {};
+ const keywords = (trigger.keywords || []).join(", ") || "—";
+ const queries = (item.queries || []).slice(0, 3).join(", ");
+ const hasMore = (item.queries || []).length > 3;
+
+ el.innerHTML = `
+
+ ${item.rule_key}
+ ${item.active ? "Activa" : "Inactiva"}
+ P: ${item.priority}
+
+
Keywords: ${keywords}
+
→ ${queries}${hasMore ? "..." : ""}
+ `;
+
+ el.onclick = () => {
+ this.selected = item;
+ this.editMode = "edit";
+ this.renderList();
+ this.renderForm();
+ };
+
+ list.appendChild(el);
+ }
+ }
+
+ showCreateForm() {
+ this.selected = null;
+ this.editMode = "create";
+ this.renderList();
+ this.renderForm();
+ }
+
+ renderForm() {
+ const form = this.shadowRoot.getElementById("form");
+ const title = this.shadowRoot.getElementById("formTitle");
+
+ if (!this.editMode) {
+ title.textContent = "Detalle";
+ form.innerHTML = `
Seleccioná una regla o creá una nueva
`;
+ return;
+ }
+
+ const isCreate = this.editMode === "create";
+ title.textContent = isCreate ? "Nueva Regla" : "Editar Regla";
+
+ const rule_key = this.selected?.rule_key || "";
+ const trigger = this.selected?.trigger || {};
+ const queries = this.selected?.queries || [];
+ const ask_slots = this.selected?.ask_slots || [];
+ const active = this.selected?.active !== false;
+ const priority = this.selected?.priority || 100;
+
+ // Convert arrays to comma-separated strings for display
+ const triggerKeywords = (trigger.keywords || []).join(", ");
+ const queriesText = (queries || []).join(", ");
+ const askSlotsText = Array.isArray(ask_slots)
+ ? ask_slots.map(s => typeof s === "string" ? s : s?.slot || s?.keyword || "").filter(Boolean).join(", ")
+ : "";
+
+ form.innerHTML = `
+
+
+
+
Sin espacios, en minusculas con guiones bajos
+
+
+
+
+
+
+
Mayor = primero
+
+
+
+
+
+
+
+
+
+
+
Palabras que activan esta regla, separadas por coma
+
+
+
+
+
+
Productos a buscar cuando se activa la regla, separados por coma
+
+
+
+
+
+
El bot preguntara al usuario sobre estos temas de forma natural
+
+
+
+
+ ${!isCreate ? `` : ""}
+
+
+ `;
+
+ this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
+ this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
+ if (!isCreate) {
+ this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
+ }
+ }
+
+ parseCommaSeparated(str) {
+ return String(str || "")
+ .split(",")
+ .map(s => s.trim().toLowerCase())
+ .filter(Boolean);
+ }
+
+ async save() {
+ const ruleKey = this.shadowRoot.getElementById("ruleKeyInput").value.trim().toLowerCase().replace(/\s+/g, "_");
+ const priority = parseInt(this.shadowRoot.getElementById("priorityInput").value, 10) || 100;
+ const active = this.shadowRoot.getElementById("activeInput").checked;
+
+ // Parse comma-separated values into arrays
+ const triggerKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("triggerInput").value);
+ const queries = this.parseCommaSeparated(this.shadowRoot.getElementById("queriesInput").value);
+ const askSlotsKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("askSlotsInput").value);
+
+ if (!ruleKey) {
+ alert("El rule_key es requerido");
+ return;
+ }
+
+ // Build trigger object with keywords array
+ const trigger = triggerKeywords.length > 0 ? { keywords: triggerKeywords } : {};
+
+ // Ask slots as simple array of keywords (LLM will formulate questions naturally)
+ const ask_slots = askSlotsKeywords;
+
+ const data = {
+ rule_key: ruleKey,
+ trigger,
+ queries,
+ ask_slots,
+ active,
+ priority,
+ };
+
+ try {
+ if (this.editMode === "create") {
+ await api.createRecommendation(data);
+ } else {
+ await api.updateRecommendation(this.selected.id, data);
+ }
+ this.editMode = null;
+ this.selected = null;
+ await this.load();
+ this.renderForm();
+ } catch (e) {
+ console.error("Error saving recommendation:", e);
+ alert("Error guardando: " + (e.message || e));
+ }
+ }
+
+ async delete() {
+ if (!this.selected?.id) return;
+ if (!confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`)) return;
+
+ try {
+ await api.deleteRecommendation(this.selected.id);
+ this.editMode = null;
+ this.selected = null;
+ await this.load();
+ this.renderForm();
+ } catch (e) {
+ console.error("Error deleting recommendation:", e);
+ alert("Error eliminando: " + (e.message || e));
+ }
+ }
+
+ cancel() {
+ this.editMode = null;
+ this.selected = null;
+ this.renderList();
+ this.renderForm();
+ }
+}
+
+customElements.define("recommendations-crud", RecommendationsCrud);
diff --git a/public/components/run-timeline.js b/public/components/run-timeline.js
index 3c208e9..2a2fade 100644
--- a/public/components/run-timeline.js
+++ b/public/components/run-timeline.js
@@ -10,17 +10,18 @@ class RunTimeline extends HTMLElement {
this.shadowRoot.innerHTML = `
+
+
+
+
+
+
Detalle
+
+
Selecciona un usuario
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ this.shadowRoot.getElementById("search").oninput = (e) => {
+ this.searchQuery = e.target.value;
+ clearTimeout(this._searchTimer);
+ this._searchTimer = setTimeout(() => this.load(), 300);
+ };
+
+ this.load();
+ }
+
+ async load() {
+ this.loading = true;
+ this.renderList();
+
+ try {
+ const data = await api.users({ q: this.searchQuery, limit: 500 });
+ this.items = data.items || [];
+ this.loading = false;
+ this.renderList();
+ this.renderStats();
+ } catch (e) {
+ console.error("Error loading users:", e);
+ this.items = [];
+ this.loading = false;
+ this.renderList();
+ }
+ }
+
+ renderStats() {
+ const total = this.items.length;
+ const withWoo = this.items.filter(u => u.external_customer_id).length;
+
+ this.shadowRoot.getElementById("totalCount").textContent = total;
+ this.shadowRoot.getElementById("wooCount").textContent = withWoo;
+ }
+
+ renderList() {
+ const list = this.shadowRoot.getElementById("list");
+
+ if (this.loading) {
+ list.innerHTML = `
Cargando...
`;
+ return;
+ }
+
+ if (!this.items.length) {
+ list.innerHTML = `
No se encontraron usuarios
`;
+ return;
+ }
+
+ list.innerHTML = "";
+ for (const item of this.items) {
+ const el = document.createElement("div");
+ el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : "");
+
+ const name = item.push_name || item.chat_id.replace(/@.+$/, "");
+ const wooBadge = item.external_customer_id
+ ? `
Woo: ${item.external_customer_id}`
+ : "";
+
+ el.innerHTML = `
+
${name} ${wooBadge}
+
${item.chat_id}
+ `;
+
+ el.onclick = () => {
+ this.selected = item;
+ this.renderList();
+ this.renderDetail();
+ };
+
+ list.appendChild(el);
+ }
+ }
+
+ renderDetail() {
+ const detail = this.shadowRoot.getElementById("detail");
+ const title = this.shadowRoot.getElementById("detailTitle");
+
+ if (!this.selected) {
+ title.textContent = "Detalle";
+ detail.innerHTML = `
Selecciona un usuario
`;
+ return;
+ }
+
+ const u = this.selected;
+ const name = u.push_name || u.chat_id.replace(/@.+$/, "");
+ title.textContent = name;
+
+ detail.innerHTML = `
+
+
+
${u.chat_id}
+
+
+
+
${u.push_name || "—"}
+
+
+
+
${u.chat_id.replace(/@.+$/, "")}
+
+
+
+
${u.external_customer_id || "Sin vincular"}
+
+
+
+
${u.provider || "—"}
+
+
+
+
${u.created_at ? new Date(u.created_at).toLocaleString() : "—"}
+
+
+
+
${u.updated_at ? new Date(u.updated_at).toLocaleString() : "—"}
+
+
+
+
+
+
+ `;
+
+ detail.scrollTop = 0;
+
+ this.shadowRoot.getElementById("openChat").onclick = () => {
+ emit("ui:selectedChat", { chat_id: u.chat_id });
+ emit("ui:switchView", { view: "chat" });
+ };
+
+ this.shadowRoot.getElementById("deleteConv").onclick = async () => {
+ if (!confirm(`¿Eliminar la conversacion de "${u.chat_id}"?`)) return;
+ try {
+ await api.deleteConversation(u.chat_id);
+ alert("Conversacion eliminada");
+ } catch (e) {
+ alert("Error: " + (e.message || e));
+ }
+ };
+
+ this.shadowRoot.getElementById("deleteUser").onclick = async () => {
+ if (!confirm(`¿Eliminar usuario "${u.chat_id}", su conversacion y el customer en Woo?`)) return;
+ try {
+ await api.deleteUser(u.chat_id, { deleteWoo: true });
+ this.selected = null;
+ await this.load();
+ this.renderDetail();
+ } catch (e) {
+ alert("Error: " + (e.message || e));
+ }
+ };
+ }
+}
+
+customElements.define("users-crud", UsersCrud);
diff --git a/public/lib/api.js b/public/lib/api.js
index 71afa85..8e4a08b 100644
--- a/public/lib/api.js
+++ b/public/lib/api.js
@@ -57,4 +57,79 @@ export const api = {
if (!chat_id) throw new Error("chat_id_required");
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
},
+
+ // Products CRUD
+ async products({ q = "", limit = 2000, offset = 0 } = {}) {
+ const u = new URL("/products", location.origin);
+ if (q) u.searchParams.set("q", q);
+ u.searchParams.set("limit", String(limit));
+ u.searchParams.set("offset", String(offset));
+ return fetch(u).then(r => r.json());
+ },
+
+ async productById(id) {
+ if (!id) return null;
+ return fetch(`/products/${encodeURIComponent(id)}`).then(r => r.json());
+ },
+
+ async syncProducts() {
+ return fetch("/products/sync", { method: "POST" }).then(r => r.json());
+ },
+
+ // Aliases CRUD
+ async aliases({ q = "", woo_product_id = null, limit = 200 } = {}) {
+ const u = new URL("/aliases", location.origin);
+ if (q) u.searchParams.set("q", q);
+ if (woo_product_id) u.searchParams.set("woo_product_id", String(woo_product_id));
+ u.searchParams.set("limit", String(limit));
+ return fetch(u).then(r => r.json());
+ },
+
+ async createAlias(data) {
+ return fetch("/aliases", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }).then(r => r.json());
+ },
+
+ async updateAlias(alias, data) {
+ return fetch(`/aliases/${encodeURIComponent(alias)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }).then(r => r.json());
+ },
+
+ async deleteAlias(alias) {
+ return fetch(`/aliases/${encodeURIComponent(alias)}`, { method: "DELETE" }).then(r => r.json());
+ },
+
+ // Recommendations CRUD
+ async recommendations({ q = "", limit = 200 } = {}) {
+ const u = new URL("/recommendations", location.origin);
+ if (q) u.searchParams.set("q", q);
+ u.searchParams.set("limit", String(limit));
+ return fetch(u).then(r => r.json());
+ },
+
+ async createRecommendation(data) {
+ return fetch("/recommendations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }).then(r => r.json());
+ },
+
+ async updateRecommendation(id, data) {
+ return fetch(`/recommendations/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }).then(r => r.json());
+ },
+
+ async deleteRecommendation(id) {
+ return fetch(`/recommendations/${encodeURIComponent(id)}`, { method: "DELETE" }).then(r => r.json());
+ },
};
diff --git a/scripts/import-woo-snapshot.mjs b/scripts/import-woo-snapshot.mjs
index 7b48b4f..514650c 100644
--- a/scripts/import-woo-snapshot.mjs
+++ b/scripts/import-woo-snapshot.mjs
@@ -1,3 +1,4 @@
+import "dotenv/config";
import fs from "fs";
import path from "path";
import { parse } from "csv-parse/sync";
@@ -62,7 +63,7 @@ function extractAttributes(row) {
}
function normalizeRow(row) {
- const wooId = Number(row["ID"] || row["Id"] || row["id"] || null);
+ const wooId = Number(row["ID"] || row["ID"] || row["\uFEFFID"] || 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();
@@ -194,7 +195,13 @@ 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 records = parse(content, {
+ columns: true,
+ skip_empty_lines: true,
+ relax_column_count: true,
+ relax_column_count_less: true,
+ relax_column_count_more: 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);
diff --git a/src/modules/0-ui/controllers/aliases.js b/src/modules/0-ui/controllers/aliases.js
new file mode 100644
index 0000000..8741f20
--- /dev/null
+++ b/src/modules/0-ui/controllers/aliases.js
@@ -0,0 +1,68 @@
+import { handleListAliases, handleCreateAlias, handleUpdateAlias, handleDeleteAlias } from "../handlers/aliases.js";
+
+export const makeListAliases = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const q = req.query.q || "";
+ const woo_product_id = req.query.woo_product_id ? parseInt(req.query.woo_product_id, 10) : null;
+ const limit = req.query.limit || "200";
+ const result = await handleListAliases({ tenantId, q, woo_product_id, limit });
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const { alias, woo_product_id, boost, category_hint, metadata } = req.body || {};
+
+ if (!alias || !woo_product_id) {
+ return res.status(400).json({ ok: false, error: "alias_and_woo_product_id_required" });
+ }
+
+ const result = await handleCreateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
+ res.json({ ok: true, item: result });
+ } catch (err) {
+ console.error(err);
+ if (err.code === "23505") { // unique violation
+ return res.status(409).json({ ok: false, error: "alias_already_exists" });
+ }
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeUpdateAlias = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const alias = req.params.alias;
+ const { woo_product_id, boost, category_hint, metadata } = req.body || {};
+
+ if (!woo_product_id) {
+ return res.status(400).json({ ok: false, error: "woo_product_id_required" });
+ }
+
+ const result = await handleUpdateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
+ if (!result) {
+ return res.status(404).json({ ok: false, error: "alias_not_found" });
+ }
+ res.json({ ok: true, item: result });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeDeleteAlias = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const alias = req.params.alias;
+ const result = await handleDeleteAlias({ tenantId, alias });
+ res.json({ ok: true, ...result });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
diff --git a/src/modules/0-ui/controllers/products.js b/src/modules/0-ui/controllers/products.js
index 44ef23e..6c0c244 100644
--- a/src/modules/0-ui/controllers/products.js
+++ b/src/modules/0-ui/controllers/products.js
@@ -1,4 +1,4 @@
-import { handleSearchProducts } from "../handlers/products.js";
+import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts } from "../handlers/products.js";
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
try {
@@ -14,3 +14,43 @@ export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
}
};
+export const makeListProducts = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const q = req.query.q || "";
+ const limit = req.query.limit || "2000";
+ const offset = req.query.offset || "0";
+ const result = await handleListProducts({ tenantId, q, limit, offset });
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeGetProduct = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const wooProductId = req.params.id;
+ const result = await handleGetProduct({ tenantId, wooProductId });
+ if (!result) {
+ return res.status(404).json({ ok: false, error: "product_not_found" });
+ }
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const result = await handleSyncProducts({ tenantId });
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
diff --git a/src/modules/0-ui/controllers/recommendations.js b/src/modules/0-ui/controllers/recommendations.js
new file mode 100644
index 0000000..35bbb5c
--- /dev/null
+++ b/src/modules/0-ui/controllers/recommendations.js
@@ -0,0 +1,88 @@
+import {
+ handleListRecommendations,
+ handleGetRecommendation,
+ handleCreateRecommendation,
+ handleUpdateRecommendation,
+ handleDeleteRecommendation
+} from "../handlers/recommendations.js";
+
+export const makeListRecommendations = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const q = req.query.q || "";
+ const limit = req.query.limit || "200";
+ const result = await handleListRecommendations({ tenantId, q, limit });
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const id = req.params.id;
+ const result = await handleGetRecommendation({ tenantId, id });
+ if (!result) {
+ return res.status(404).json({ ok: false, error: "recommendation_not_found" });
+ }
+ res.json(result);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const { rule_key, trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
+
+ if (!rule_key) {
+ return res.status(400).json({ ok: false, error: "rule_key_required" });
+ }
+
+ const result = await handleCreateRecommendation({
+ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority
+ });
+ res.json({ ok: true, item: result });
+ } catch (err) {
+ console.error(err);
+ if (err.code === "23505") { // unique violation
+ return res.status(409).json({ ok: false, error: "rule_key_already_exists" });
+ }
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeUpdateRecommendation = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const id = req.params.id;
+ const { trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
+
+ const result = await handleUpdateRecommendation({
+ tenantId, id, trigger, queries, boosts, ask_slots, active, priority
+ });
+ if (!result) {
+ return res.status(404).json({ ok: false, error: "recommendation_not_found" });
+ }
+ res.json({ ok: true, item: result });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
+
+export const makeDeleteRecommendation = (tenantIdOrFn) => async (req, res) => {
+ try {
+ const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
+ const id = req.params.id;
+ const result = await handleDeleteRecommendation({ tenantId, id });
+ res.json({ ok: true, ...result });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ ok: false, error: "internal_error" });
+ }
+};
diff --git a/src/modules/0-ui/db/repo.js b/src/modules/0-ui/db/repo.js
new file mode 100644
index 0000000..b1f20e8
--- /dev/null
+++ b/src/modules/0-ui/db/repo.js
@@ -0,0 +1,296 @@
+import { pool } from "../../shared/db/pool.js";
+
+// ─────────────────────────────────────────────────────────────
+// Products
+// ─────────────────────────────────────────────────────────────
+
+export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
+ const lim = Math.max(1, Math.min(5000, parseInt(limit, 10) || 2000));
+ const off = Math.max(0, parseInt(offset, 10) || 0);
+ const query = String(q || "").trim();
+
+ let sql, params;
+ if (query) {
+ const like = `%${query}%`;
+ sql = `
+ select
+ woo_id as woo_product_id,
+ name,
+ slug as sku,
+ price_current as price,
+ stock_status,
+ categories,
+ attributes_normalized,
+ updated_at as refreshed_at,
+ raw as payload
+ from woo_products_snapshot
+ where tenant_id = $1
+ and (name ilike $2 or coalesce(slug,'') ilike $2)
+ order by name asc
+ limit $3 offset $4
+ `;
+ params = [tenantId, like, lim, off];
+ } else {
+ sql = `
+ select
+ woo_id as woo_product_id,
+ name,
+ slug as sku,
+ price_current as price,
+ stock_status,
+ categories,
+ attributes_normalized,
+ updated_at as refreshed_at,
+ raw as payload
+ from woo_products_snapshot
+ where tenant_id = $1
+ order by name asc
+ limit $2 offset $3
+ `;
+ params = [tenantId, lim, off];
+ }
+
+ const { rows } = await pool.query(sql, params);
+ return rows;
+}
+
+export async function getProductByWooId({ tenantId, wooProductId }) {
+ const sql = `
+ select
+ woo_id as woo_product_id,
+ name,
+ slug as sku,
+ price_current as price,
+ stock_status,
+ categories,
+ attributes_normalized,
+ updated_at as refreshed_at,
+ raw as payload
+ from woo_products_snapshot
+ where tenant_id = $1 and woo_id = $2
+ limit 1
+ `;
+ const { rows } = await pool.query(sql, [tenantId, wooProductId]);
+ return rows[0] || null;
+}
+
+// ─────────────────────────────────────────────────────────────
+// Aliases
+// ─────────────────────────────────────────────────────────────
+
+function normalizeAlias(alias) {
+ return String(alias || "")
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .trim();
+}
+
+export async function listAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
+ const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
+ const query = String(q || "").trim();
+
+ let sql, params;
+ if (woo_product_id) {
+ sql = `
+ select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
+ from product_aliases
+ where tenant_id = $1 and woo_product_id = $2
+ order by alias asc
+ limit $3
+ `;
+ params = [tenantId, woo_product_id, lim];
+ } else if (query) {
+ const like = `%${query}%`;
+ sql = `
+ select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
+ from product_aliases
+ where tenant_id = $1 and (alias ilike $2 or normalized_alias ilike $2)
+ order by alias asc
+ limit $3
+ `;
+ params = [tenantId, like, lim];
+ } else {
+ sql = `
+ select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
+ from product_aliases
+ where tenant_id = $1
+ order by alias asc
+ limit $2
+ `;
+ params = [tenantId, lim];
+ }
+
+ const { rows } = await pool.query(sql, params);
+ return rows;
+}
+
+export async function insertAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
+ const normalizedAlias = normalizeAlias(alias);
+
+ const sql = `
+ insert into product_aliases (tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata)
+ values ($1, $2, $3, $4, $5, $6, $7)
+ returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
+ `;
+
+ const { rows } = await pool.query(sql, [
+ tenantId,
+ alias.toLowerCase().trim(),
+ normalizedAlias,
+ woo_product_id,
+ category_hint,
+ boost || 0,
+ JSON.stringify(metadata || {}),
+ ]);
+
+ return rows[0];
+}
+
+export async function updateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
+ const normalizedAlias = normalizeAlias(alias);
+
+ const sql = `
+ update product_aliases
+ set woo_product_id = $3, category_hint = $4, boost = $5, metadata = $6, normalized_alias = $7, updated_at = now()
+ where tenant_id = $1 and alias = $2
+ returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
+ `;
+
+ const { rows } = await pool.query(sql, [
+ tenantId,
+ alias.toLowerCase().trim(),
+ woo_product_id,
+ category_hint,
+ boost || 0,
+ JSON.stringify(metadata || {}),
+ normalizedAlias,
+ ]);
+
+ return rows[0] || null;
+}
+
+export async function deleteAlias({ tenantId, alias }) {
+ const sql = `delete from product_aliases where tenant_id = $1 and alias = $2 returning alias`;
+ const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim()]);
+ return rows.length > 0;
+}
+
+// ─────────────────────────────────────────────────────────────
+// Recommendations
+// ─────────────────────────────────────────────────────────────
+
+export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
+ const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
+ const query = String(q || "").trim();
+
+ let sql, params;
+ if (query) {
+ const like = `%${query}%`;
+ sql = `
+ select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ from product_reco_rules
+ where tenant_id = $1 and rule_key ilike $2
+ order by priority desc, rule_key asc
+ limit $3
+ `;
+ params = [tenantId, like, lim];
+ } else {
+ sql = `
+ select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ from product_reco_rules
+ where tenant_id = $1
+ order by priority desc, rule_key asc
+ limit $2
+ `;
+ params = [tenantId, lim];
+ }
+
+ const { rows } = await pool.query(sql, params);
+ return rows;
+}
+
+export async function getRecommendationById({ tenantId, id }) {
+ const sql = `
+ select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ from product_reco_rules
+ where tenant_id = $1 and id = $2
+ limit 1
+ `;
+ const { rows } = await pool.query(sql, [tenantId, id]);
+ return rows[0] || null;
+}
+
+export async function insertRecommendation({
+ tenantId,
+ rule_key,
+ trigger = {},
+ queries = [],
+ boosts = {},
+ ask_slots = [],
+ active = true,
+ priority = 100,
+}) {
+ const sql = `
+ insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
+ values ($1, $2, $3, $4, $5, $6, $7, $8)
+ returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ `;
+
+ const { rows } = await pool.query(sql, [
+ tenantId,
+ rule_key.toLowerCase().trim(),
+ JSON.stringify(trigger || {}),
+ JSON.stringify(queries || []),
+ JSON.stringify(boosts || {}),
+ JSON.stringify(ask_slots || []),
+ active !== false,
+ priority || 100,
+ ]);
+
+ return rows[0];
+}
+
+export async function updateRecommendation({
+ tenantId,
+ id,
+ trigger,
+ queries,
+ boosts,
+ ask_slots,
+ active,
+ priority,
+}) {
+ const sql = `
+ update product_reco_rules
+ set
+ trigger = $3,
+ queries = $4,
+ boosts = $5,
+ ask_slots = $6,
+ active = $7,
+ priority = $8,
+ updated_at = now()
+ where tenant_id = $1 and id = $2
+ returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ `;
+
+ const { rows } = await pool.query(sql, [
+ tenantId,
+ id,
+ JSON.stringify(trigger || {}),
+ JSON.stringify(queries || []),
+ JSON.stringify(boosts || {}),
+ JSON.stringify(ask_slots || []),
+ active !== false,
+ priority || 100,
+ ]);
+
+ return rows[0] || null;
+}
+
+export async function deleteRecommendation({ tenantId, id }) {
+ const sql = `delete from product_reco_rules where tenant_id = $1 and id = $2 returning id`;
+ const { rows } = await pool.query(sql, [tenantId, id]);
+ return rows.length > 0;
+}
diff --git a/src/modules/0-ui/handlers/aliases.js b/src/modules/0-ui/handlers/aliases.js
new file mode 100644
index 0000000..fcc1a98
--- /dev/null
+++ b/src/modules/0-ui/handlers/aliases.js
@@ -0,0 +1,19 @@
+import { listAliases, insertAlias, updateAlias, deleteAlias } from "../db/repo.js";
+
+export async function handleListAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
+ const items = await listAliases({ tenantId, q, woo_product_id, limit });
+ return { items };
+}
+
+export async function handleCreateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
+ return insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
+}
+
+export async function handleUpdateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
+ return updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
+}
+
+export async function handleDeleteAlias({ tenantId, alias }) {
+ const deleted = await deleteAlias({ tenantId, alias });
+ return { deleted };
+}
diff --git a/src/modules/0-ui/handlers/products.js b/src/modules/0-ui/handlers/products.js
index 18e3abb..6c26ed3 100644
--- a/src/modules/0-ui/handlers/products.js
+++ b/src/modules/0-ui/handlers/products.js
@@ -1,4 +1,5 @@
import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
+import { listProducts, getProductByWooId } from "../db/repo.js";
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
const { items, source } = await searchSnapshotItems({
@@ -9,3 +10,18 @@ export async function handleSearchProducts({ tenantId, q = "", limit = "10", for
return { items, source };
}
+export async function handleListProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
+ const items = await listProducts({ tenantId, q, limit, offset });
+ return { items };
+}
+
+export async function handleGetProduct({ tenantId, wooProductId }) {
+ return getProductByWooId({ tenantId, wooProductId });
+}
+
+export async function handleSyncProducts({ tenantId }) {
+ // This is a placeholder - actual sync would fetch from Woo API
+ // For now, just return success
+ return { ok: true, message: "Sync triggered (use import script for full sync)" };
+}
+
diff --git a/src/modules/0-ui/handlers/recommendations.js b/src/modules/0-ui/handlers/recommendations.js
new file mode 100644
index 0000000..1e19089
--- /dev/null
+++ b/src/modules/0-ui/handlers/recommendations.js
@@ -0,0 +1,47 @@
+import {
+ listRecommendations,
+ getRecommendationById,
+ insertRecommendation,
+ updateRecommendation,
+ deleteRecommendation,
+} from "../db/repo.js";
+
+export async function handleListRecommendations({ tenantId, q = "", limit = 200 }) {
+ const items = await listRecommendations({ tenantId, q, limit });
+ return { items };
+}
+
+export async function handleGetRecommendation({ tenantId, id }) {
+ return getRecommendationById({ tenantId, id });
+}
+
+export async function handleCreateRecommendation({
+ tenantId,
+ rule_key,
+ trigger = {},
+ queries = [],
+ boosts = {},
+ ask_slots = [],
+ active = true,
+ priority = 100,
+}) {
+ return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority });
+}
+
+export async function handleUpdateRecommendation({
+ tenantId,
+ id,
+ trigger,
+ queries,
+ boosts,
+ ask_slots,
+ active,
+ priority,
+}) {
+ return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority });
+}
+
+export async function handleDeleteRecommendation({ tenantId, id }) {
+ const deleted = await deleteRecommendation({ tenantId, id });
+ return { deleted };
+}
diff --git a/src/modules/1-intake/handlers/evolution.js b/src/modules/1-intake/handlers/evolution.js
index 034f8ad..c383915 100644
--- a/src/modules/1-intake/handlers/evolution.js
+++ b/src/modules/1-intake/handlers/evolution.js
@@ -6,6 +6,26 @@ import { debug as dbg } from "../../shared/debug.js";
export async function handleEvolutionWebhook(body) {
const t0 = Date.now();
const parsed = parseEvolutionWebhook(body);
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H1",
+ location: "evolution.js:9",
+ message: "parsed_webhook",
+ data: {
+ ok: parsed?.ok,
+ reason: parsed?.reason || null,
+ has_text: Boolean(parsed?.text),
+ source: parsed?.source || null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
if (!parsed.ok) {
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
}
diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js
index e780df2..f891a66 100644
--- a/src/modules/1-intake/routes/simulator.js
+++ b/src/modules/1-intake/routes/simulator.js
@@ -6,7 +6,9 @@ import { makeListRuns, makeGetRunById } from "../../0-ui/controllers/runs.js";
import { makeSimSend } from "../controllers/sim.js";
import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js";
import { makeListMessages } from "../../0-ui/controllers/messages.js";
-import { makeSearchProducts } from "../../0-ui/controllers/products.js";
+import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts } from "../../0-ui/controllers/products.js";
+import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
+import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
function nowIso() {
@@ -49,7 +51,22 @@ export function createSimulatorRouter({ tenantId }) {
router.delete("/conversations/:chat_id", makeDeleteConversation(getTenantId));
router.post("/conversations/:chat_id/retry-last", makeRetryLast(getTenantId));
router.get("/messages", makeListMessages(getTenantId));
- router.get("/products", makeSearchProducts(getTenantId));
+ router.get("/products", makeListProducts(getTenantId));
+ router.get("/products/search", makeSearchProducts(getTenantId));
+ router.get("/products/:id", makeGetProduct(getTenantId));
+ router.post("/products/sync", makeSyncProducts(getTenantId));
+
+ router.get("/aliases", makeListAliases(getTenantId));
+ router.post("/aliases", makeCreateAlias(getTenantId));
+ router.put("/aliases/:alias", makeUpdateAlias(getTenantId));
+ router.delete("/aliases/:alias", makeDeleteAlias(getTenantId));
+
+ router.get("/recommendations", makeListRecommendations(getTenantId));
+ router.get("/recommendations/:id", makeGetRecommendation(getTenantId));
+ router.post("/recommendations", makeCreateRecommendation(getTenantId));
+ router.put("/recommendations/:id", makeUpdateRecommendation(getTenantId));
+ router.delete("/recommendations/:id", makeDeleteRecommendation(getTenantId));
+
router.get("/users", makeListUsers(getTenantId));
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));
diff --git a/src/modules/2-identity/db/repo.js b/src/modules/2-identity/db/repo.js
index 138991b..1ba27f8 100644
--- a/src/modules/2-identity/db/repo.js
+++ b/src/modules/2-identity/db/repo.js
@@ -65,7 +65,7 @@ export async function touchConversationState({ tenant_id, wa_chat_id }) {
on conflict (tenant_id, wa_chat_id)
do update set
updated_at = now()
- returning tenant_id, wa_chat_id, state, last_intent, context, updated_at
+ returning tenant_id, wa_chat_id, state, last_intent, last_order_id, context, state_updated_at, updated_at, created_at
`;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
return rows[0] || null;
@@ -272,10 +272,16 @@ export async function getRunById({ tenant_id, run_id }) {
export async function getRecentMessagesForLLM({
tenant_id,
wa_chat_id,
- limit = 20,
- maxCharsPerMessage = 800,
}) {
- const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 20));
+ const limRaw = parseInt(process.env.LIMIT_CONVERSATIONS || "", 10);
+ const maxCharsRaw = parseInt(process.env.MAX_CHARS_PER_MESSAGE || "", 10);
+ if (!Number.isFinite(limRaw) || limRaw <= 0) {
+ throw new Error("LIMIT_CONVERSATIONS env is required and must be a positive integer");
+ }
+ if (!Number.isFinite(maxCharsRaw) || maxCharsRaw <= 0) {
+ throw new Error("MAX_CHARS_PER_MESSAGE env is required and must be a positive integer");
+ }
+ const lim = Math.max(1, Math.min(50, limRaw));
const q = `
select direction, ts, text
from wa_messages
@@ -290,7 +296,7 @@ export async function getRecentMessagesForLLM({
return rows.reverse().map((r) => ({
role: r.direction === "in" ? "user" : "assistant",
- content: String(r.text).trim().slice(0, maxCharsPerMessage),
+ content: String(r.text).trim().slice(0, maxCharsRaw),
}));
}
@@ -557,6 +563,28 @@ export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
}));
}
+export async function getRecoRules({ tenant_id }) {
+ const sql = `
+ select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ from product_reco_rules
+ where tenant_id=$1 and active=true
+ order by priority asc, id asc
+ `;
+ const { rows } = await pool.query(sql, [tenant_id]);
+ return rows;
+}
+
+export async function getRecoRuleByKey({ tenant_id, rule_key }) {
+ const sql = `
+ select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
+ from product_reco_rules
+ where tenant_id=$1 and rule_key=$2
+ limit 1
+ `;
+ const { rows } = await pool.query(sql, [tenant_id, rule_key]);
+ return rows[0] || null;
+}
+
export async function getProductEmbedding({ tenant_id, content_hash }) {
const sql = `
select tenant_id, content_hash, content_text, embedding, model, updated_at
diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js
index 8f31997..a08da75 100644
--- a/src/modules/2-identity/services/pipeline.js
+++ b/src/modules/2-identity/services/pipeline.js
@@ -1,6 +1,5 @@
import crypto from "crypto";
import {
- getConversationState,
insertMessage,
insertRun,
touchConversationState,
@@ -124,17 +123,56 @@ export async function processMessage({
meta = null,
}) {
const { started_at, mark, msBetween } = makePerf();
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H2",
+ location: "pipeline.js:128",
+ message: "processMessage_enter",
+ data: {
+ tenantId: tenantId || null,
+ provider,
+ chat_id: chat_id || null,
+ text_len: String(text || "").length,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
- await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
+ const prev = 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");
+ mark("after_touchConversationState");
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";
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H3",
+ location: "pipeline.js:150",
+ message: "conversation_state_loaded",
+ data: {
+ prev_state,
+ isStale: Boolean(isStale),
+ state_updated_at: prev?.state_updated_at || null,
+ has_context: Boolean(prev?.context && typeof prev?.context === "object"),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
let externalCustomerId = await getExternalCustomerIdByChat({
tenant_id: tenantId,
wa_chat_id: chat_id,
@@ -158,7 +196,6 @@ export async function processMessage({
const history = await getRecentMessagesForLLM({
tenant_id: tenantId,
wa_chat_id: chat_id,
- limit: 20,
});
const conversation_history = collapseAssistantMessages(history);
mark("after_getRecentMessagesForLLM_for_plan");
@@ -185,6 +222,26 @@ export async function processMessage({
llmMeta = { kind: "nlu_v3", audit: decision.audit || null };
tools = [];
mark("after_turn_v3");
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H4",
+ location: "pipeline.js:198",
+ message: "turn_v3_result",
+ data: {
+ intent: plan?.intent || null,
+ next_state: plan?.next_state || null,
+ missing_fields: Array.isArray(plan?.missing_fields) ? plan.missing_fields.length : null,
+ actions_count: Array.isArray(decision?.actions) ? decision.actions.length : null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
const runStatus = llmMeta?.error ? "warn" : "ok";
const isSimulated = provider === "sim" || meta?.source === "sim";
@@ -397,8 +454,8 @@ export async function processMessage({
run_id,
end_to_end_ms,
ms: {
- db_state_ms: msBetween("start", "after_getConversationState"),
- db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
+ db_state_ms: msBetween("start", "after_touchConversationState"),
+ db_identity_ms: msBetween("after_touchConversationState", "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"),
diff --git a/src/modules/3-turn-engine/catalogRetrieval.js b/src/modules/3-turn-engine/catalogRetrieval.js
index f024312..c6778bc 100644
--- a/src/modules/3-turn-engine/catalogRetrieval.js
+++ b/src/modules/3-turn-engine/catalogRetrieval.js
@@ -154,6 +154,26 @@ export async function retrieveCandidates({
limit: lim,
});
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H9",
+ location: "catalogRetrieval.js:158",
+ message: "catalog_sources",
+ data: {
+ query: q,
+ aliases_count: aliases.length,
+ snapshot_count: wooItems?.length || 0,
+ snapshot_source: wooSource || null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
let candidates = (wooItems || []).map((c) => {
const lit = literalScore(q, c);
diff --git a/src/modules/3-turn-engine/openai.js b/src/modules/3-turn-engine/openai.js
index 0019fa9..ffb9c8a 100644
--- a/src/modules/3-turn-engine/openai.js
+++ b/src/modules/3-turn-engine/openai.js
@@ -75,14 +75,14 @@ const NluV3JsonSchema = {
properties: {
intent: {
type: "string",
- enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "other"],
+ enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "recommend", "other"],
},
confidence: { type: "number", minimum: 0, maximum: 1 },
language: { type: "string" },
entities: {
type: "object",
additionalProperties: false,
- required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation"],
+ required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation", "items"],
properties: {
product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
@@ -103,6 +103,25 @@ const NluV3JsonSchema = {
},
attributes: { type: "array", items: { type: "string" } },
preparation: { type: "array", items: { type: "string" } },
+ // Soporte para múltiples productos en un mensaje
+ items: {
+ anyOf: [
+ { type: "null" },
+ {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["product_query"],
+ properties: {
+ product_query: { type: "string", minLength: 1 },
+ quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
+ unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
+ },
+ },
+ },
+ ],
+ },
},
},
needs: {
@@ -120,6 +139,148 @@ const NluV3JsonSchema = {
const ajv = new Ajv({ allErrors: true, strict: true });
const validateNluV3 = ajv.compile(NluV3JsonSchema);
+const RecommendWriterSchema = {
+ $id: "RecommendWriter",
+ type: "object",
+ additionalProperties: false,
+ required: ["reply"],
+ properties: {
+ reply: { type: "string", minLength: 1 },
+ suggested_actions: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["type"],
+ properties: {
+ type: { type: "string", enum: ["add_to_cart"] },
+ product_id: { anyOf: [{ type: "number" }, { type: "null" }] },
+ quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
+ unit: { anyOf: [{ type: "string" }, { type: "null" }] },
+ },
+ },
+ },
+ },
+};
+
+const validateRecommendWriter = ajv.compile(RecommendWriterSchema);
+
+function normalizeUnitValue(unit) {
+ if (!unit) return null;
+ const u = String(unit).trim().toLowerCase();
+ if (["kg", "kilo", "kilos", "kgs", "kg.", "kilogramo", "kilogramos"].includes(u)) return "kg";
+ if (["g", "gr", "gr.", "gramo", "gramos"].includes(u)) return "g";
+ if (["unidad", "unidades", "unit", "u"].includes(u)) return "unidad";
+ return null;
+}
+
+function inferSelectionFromText(text) {
+ const t = String(text || "").toLowerCase();
+ const m = /\b(\d{1,2})\b/.exec(t);
+ if (m) return { type: "index", value: String(m[1]) };
+ if (/\bprimera\b|\bprimero\b/.test(t)) return { type: "index", value: "1" };
+ if (/\bsegunda\b|\bsegundo\b/.test(t)) return { type: "index", value: "2" };
+ if (/\btercera\b|\btercero\b/.test(t)) return { type: "index", value: "3" };
+ if (/\bcuarta\b|\bcuarto\b/.test(t)) return { type: "index", value: "4" };
+ if (/\bquinta\b|\bquinto\b/.test(t)) return { type: "index", value: "5" };
+ if (/\bsexta\b|\bsexto\b/.test(t)) return { type: "index", value: "6" };
+ if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return { type: "index", value: "7" };
+ if (/\boctava\b|\boctavo\b/.test(t)) return { type: "index", value: "8" };
+ if (/\bnovena\b|\bnoveno\b/.test(t)) return { type: "index", value: "9" };
+ if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return { type: "index", value: "10" };
+ return null;
+}
+
+function normalizeNluOutput(parsed, input) {
+ const base = nluV3Fallback();
+ const out = { ...base, ...(parsed && typeof parsed === "object" ? parsed : {}) };
+
+ if (parsed && typeof parsed === "object") {
+ if (typeof parsed["needs.catalog_lookup"] === "boolean") {
+ out.needs = { ...(out.needs || {}), catalog_lookup: parsed["needs.catalog_lookup"] };
+ }
+ if (typeof parsed["needs.knowledge_lookup"] === "boolean") {
+ out.needs = { ...(out.needs || {}), knowledge_lookup: parsed["needs.knowledge_lookup"] };
+ }
+ }
+
+ out.intent = NluV3JsonSchema.properties.intent.enum.includes(out.intent) ? out.intent : "other";
+ out.confidence = Number.isFinite(Number(out.confidence)) ? Number(out.confidence) : 0;
+ out.language = typeof out.language === "string" && out.language ? out.language : "es-AR";
+
+ const entities = out.entities && typeof out.entities === "object" ? out.entities : {};
+
+ // Normalizar items si existe
+ let normalizedItems = null;
+ if (Array.isArray(entities.items) && entities.items.length > 0) {
+ normalizedItems = entities.items
+ .filter((item) => item && typeof item === "object" && item.product_query)
+ .map((item) => ({
+ product_query: String(item.product_query || "").trim(),
+ quantity: Number.isFinite(Number(item.quantity)) ? Number(item.quantity) : null,
+ unit: normalizeUnitValue(item.unit),
+ }))
+ .filter((item) => item.product_query.length > 0);
+ if (normalizedItems.length === 0) normalizedItems = null;
+ }
+
+ out.entities = {
+ product_query: entities.product_query ?? null,
+ quantity: Number.isFinite(Number(entities.quantity)) ? Number(entities.quantity) : entities.quantity ?? null,
+ unit: normalizeUnitValue(entities.unit),
+ selection: entities.selection ?? null,
+ attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
+ preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
+ items: normalizedItems,
+ };
+
+ const hasPendingItem = Boolean(input?.pending_context?.pending_item);
+ const hasShownOptions = Array.isArray(input?.last_shown_options) && input.last_shown_options.length > 0;
+
+ // Solo permitir selection si hay opciones mostradas o pending_clarification
+ if (hasPendingItem || !hasShownOptions) {
+ out.entities.selection = null;
+ }
+ if (out.entities.selection && typeof out.entities.selection === "object") {
+ const sel = out.entities.selection;
+ const valueOk = typeof sel.value === "string" && sel.value.trim().length > 0;
+ const typeOk = typeof sel.type === "string" && ["index", "text", "sku"].includes(sel.type);
+ if (!valueOk || !typeOk) {
+ // Solo inferir selección si hay opciones mostradas y no hay pending_item
+ const canInfer = hasShownOptions && !hasPendingItem;
+ const inferred = canInfer ? inferSelectionFromText(input?.last_user_message) : null;
+ out.entities.selection = inferred || null;
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H11",
+ location: "openai.js:129",
+ message: "selection_inferred",
+ data: {
+ inferred: Boolean(inferred),
+ pending_item: hasPendingItem,
+ has_shown_options: hasShownOptions,
+ text: String(input?.last_user_message || "").slice(0, 20),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+ }
+ }
+
+ out.needs = {
+ catalog_lookup: Boolean(out.needs?.catalog_lookup),
+ knowledge_lookup: Boolean(out.needs?.knowledge_lookup),
+ };
+
+ return out;
+}
+
function nluV3Fallback() {
return {
intent: "other",
@@ -132,6 +293,7 @@ function nluV3Fallback() {
selection: null,
attributes: [],
preparation: [],
+ items: null,
},
needs: { catalog_lookup: false, knowledge_lookup: false },
};
@@ -154,19 +316,86 @@ export async function llmNluV3({ input, model } = {}) {
"IMPORTANTE:\n" +
"- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" +
"- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" +
- "- Si hay opciones mostradas y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" +
+ "- Incluí SIEMPRE TODAS las keys requeridas, aunque el valor sea null/[]/false.\n" +
+ "- Si hay opciones mostradas (last_shown_options no vacío) y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" +
+ "- IMPORTANTE: Si NO hay opciones mostradas (last_shown_options vacío) y el usuario dice un número + producto (ej: '2 provoletas'), eso es quantity+product_query, NO selection.\n" +
+ "- selection SOLO aplica cuando hay opciones visibles para seleccionar (last_shown_options tiene elementos).\n" +
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
- "- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n";
+ "- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n" +
+ "- Si el usuario pide recomendación/sugerencias, usá intent='recommend' y needs.catalog_lookup=true.\n" +
+ "- MULTI-ITEMS: Si el usuario menciona MÚLTIPLES productos en un mensaje (ej: '1 chimichurri y 2 provoletas'), usá entities.items con array de objetos.\n" +
+ " Ejemplo: items:[{product_query:'chimichurri',quantity:1,unit:null},{product_query:'provoletas',quantity:2,unit:null}]\n" +
+ " En este caso, product_query/quantity/unit del nivel superior quedan null.\n" +
+ "- Si es UN SOLO producto, usá product_query/quantity/unit normalmente e items=null.\n" +
+ "FORMATO JSON ESTRICTO (ejemplo, usá null/[]/false si no hay datos):\n" +
+ "{\n" +
+ " \"intent\":\"other\",\n" +
+ " \"confidence\":0,\n" +
+ " \"language\":\"es-AR\",\n" +
+ " \"entities\":{\n" +
+ " \"product_query\":null,\n" +
+ " \"quantity\":null,\n" +
+ " \"unit\":null,\n" +
+ " \"selection\":null,\n" +
+ " \"attributes\":[],\n" +
+ " \"preparation\":[],\n" +
+ " \"items\":null\n" +
+ " },\n" +
+ " \"needs\":{\n" +
+ " \"catalog_lookup\":false,\n" +
+ " \"knowledge_lookup\":false\n" +
+ " }\n" +
+ "}\n";
const user = JSON.stringify(input ?? {});
// intento 1
const first = await jsonCompletion({ system: systemBase, user, model });
- if (validateNluV3(first.parsed)) {
- return { nlu: first.parsed, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } };
+ const firstNormalized = normalizeNluOutput(first.parsed, input);
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H10",
+ location: "openai.js:196",
+ message: "nlu_normalized_first",
+ data: {
+ intent: firstNormalized?.intent || null,
+ unit: firstNormalized?.entities?.unit || null,
+ selection: firstNormalized?.entities?.selection ? "set" : "null",
+ needs_catalog: Boolean(firstNormalized?.needs?.catalog_lookup),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+ if (validateNluV3(firstNormalized)) {
+ return { nlu: firstNormalized, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } };
}
const errors1 = nluV3Errors();
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H7",
+ location: "openai.js:169",
+ message: "nlu_validation_failed_first",
+ data: {
+ errors_count: Array.isArray(errors1) ? errors1.length : null,
+ errors: Array.isArray(errors1) ? errors1.slice(0, 5) : null,
+ parsed_keys: first?.parsed ? Object.keys(first.parsed) : null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
// retry 1 vez
const systemRetry =
@@ -176,10 +405,50 @@ export async function llmNluV3({ input, model } = {}) {
try {
const second = await jsonCompletion({ system: systemRetry, user, model });
- if (validateNluV3(second.parsed)) {
- return { nlu: second.parsed, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } };
+ const secondNormalized = normalizeNluOutput(second.parsed, input);
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H10",
+ location: "openai.js:242",
+ message: "nlu_normalized_retry",
+ data: {
+ intent: secondNormalized?.intent || null,
+ unit: secondNormalized?.entities?.unit || null,
+ selection: secondNormalized?.entities?.selection ? "set" : "null",
+ needs_catalog: Boolean(secondNormalized?.needs?.catalog_lookup),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+ if (validateNluV3(secondNormalized)) {
+ return { nlu: secondNormalized, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } };
}
const errors2 = nluV3Errors();
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H7",
+ location: "openai.js:187",
+ message: "nlu_validation_failed_retry",
+ data: {
+ errors_count: Array.isArray(errors2) ? errors2.length : null,
+ errors: Array.isArray(errors2) ? errors2.slice(0, 5) : null,
+ parsed_keys: second?.parsed ? Object.keys(second.parsed) : null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
return {
nlu: nluV3Fallback(),
raw_text: second.raw_text,
@@ -200,4 +469,53 @@ export async function llmNluV3({ input, model } = {}) {
}
}
+export async function llmRecommendWriter({
+ base_item,
+ slots = {},
+ candidates = [],
+ locale = "es-AR",
+ model,
+} = {}) {
+ const system =
+ "Sos un redactor de recomendaciones (es-AR). Solo podés usar productos de la lista.\n" +
+ "NO inventes productos ni precios. Devolvé SOLO JSON con este formato:\n" +
+ "{\n" +
+ " \"reply\": \"texto final\",\n" +
+ " \"suggested_actions\": [\n" +
+ " {\"type\":\"add_to_cart\",\"product_id\":123,\"quantity\":null,\"unit\":null}\n" +
+ " ]\n" +
+ "}\n" +
+ "Si no sugerís acciones, usá suggested_actions: [].\n";
+ const user = JSON.stringify({
+ locale,
+ base_item,
+ slots,
+ candidates: candidates.map((c) => ({
+ woo_product_id: c?.woo_product_id || null,
+ name: c?.name || null,
+ price: c?.price ?? null,
+ categories: c?.categories || [],
+ })),
+ });
+ const first = await jsonCompletion({ system, user, model });
+ if (validateRecommendWriter(first.parsed)) {
+ return {
+ reply: first.parsed.reply,
+ suggested_actions: first.parsed.suggested_actions || [],
+ raw_text: first.raw_text,
+ model: first.model,
+ usage: first.usage,
+ validation: { ok: true },
+ };
+ }
+ return {
+ reply: null,
+ suggested_actions: [],
+ raw_text: first.raw_text,
+ model: first.model,
+ usage: first.usage,
+ validation: { ok: false, errors: validateRecommendWriter.errors || [] },
+ };
+}
+
// Legacy llmPlan/llmExtract y NLU v2 removidos.
diff --git a/src/modules/3-turn-engine/recommendations.js b/src/modules/3-turn-engine/recommendations.js
new file mode 100644
index 0000000..1ce39d1
--- /dev/null
+++ b/src/modules/3-turn-engine/recommendations.js
@@ -0,0 +1,217 @@
+import { getRecoRules } from "../2-identity/db/repo.js";
+import { retrieveCandidates } from "./catalogRetrieval.js";
+import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
+import { llmRecommendWriter } from "./openai.js";
+
+function normalizeText(s) {
+ return String(s || "")
+ .toLowerCase()
+ .replace(/[¿?¡!.,;:()"]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function parseYesNo(text) {
+ const t = normalizeText(text);
+ if (!t) return null;
+ if (/\b(si|sí|sisi|dale|ok|claro|obvio|tomo)\b/.test(t)) return true;
+ if (/\b(no|nop|nunca|nope|sin alcohol)\b/.test(t)) return false;
+ return null;
+}
+
+function pickBaseItem({ prev_context, basket_items }) {
+ const pending = prev_context?.pending_item;
+ if (pending?.name) {
+ return {
+ product_id: pending.product_id || null,
+ name: pending.name,
+ label: pending.name,
+ categories: pending.categories || [],
+ };
+ }
+ const items = Array.isArray(basket_items) ? basket_items : [];
+ const last = items[items.length - 1];
+ if (!last) return null;
+ return {
+ product_id: last.product_id || null,
+ name: last.label || last.name || "ese producto",
+ label: last.label || last.name || "ese producto",
+ categories: last.categories || [],
+ };
+}
+
+function ruleMatchesBase({ rule, base_item, slots }) {
+ const trigger = rule?.trigger && typeof rule.trigger === "object" ? rule.trigger : {};
+ const text = normalizeText(base_item?.name || base_item?.label || "");
+ const categories = Array.isArray(base_item?.categories) ? base_item.categories.map((c) => normalizeText(c?.name || c)) : [];
+ const keywords = Array.isArray(trigger.keywords) ? trigger.keywords.map(normalizeText).filter(Boolean) : [];
+ const cats = Array.isArray(trigger.categories) ? trigger.categories.map(normalizeText).filter(Boolean) : [];
+ const always = Boolean(trigger.always);
+ if (typeof trigger.alcohol === "boolean") {
+ if (slots?.alcohol == null) return false;
+ if (slots.alcohol !== trigger.alcohol) return false;
+ }
+ if (always) return true;
+ if (keywords.length && keywords.some((k) => text.includes(k))) return true;
+ if (cats.length && categories.some((c) => cats.includes(c))) return true;
+ return false;
+}
+
+function collectAskSlots(rules) {
+ const out = [];
+ for (const r of rules) {
+ const ask = Array.isArray(r.ask_slots) ? r.ask_slots : [];
+ for (const slot of ask) {
+ if (slot && slot.slot) out.push(slot);
+ }
+ }
+ return out;
+}
+
+function collectQueries({ rules, slots }) {
+ const out = [];
+ for (const r of rules) {
+ const q = Array.isArray(r.queries) ? r.queries : [];
+ for (const item of q) {
+ if (!item || typeof item !== "string") continue;
+ if (item.includes("{alcohol}")) {
+ const v = slots?.alcohol;
+ if (v == null) continue;
+ out.push(item.replace("{alcohol}", v ? "si" : "no"));
+ continue;
+ }
+ out.push(item);
+ }
+ }
+ return out.map((x) => x.trim()).filter(Boolean);
+}
+
+function mergeCandidates({ lists, excludeId }) {
+ const map = new Map();
+ for (const list of lists) {
+ for (const c of list || []) {
+ const id = Number(c?.woo_product_id);
+ if (!id || (excludeId && id === excludeId)) continue;
+ const prev = map.get(id);
+ if (!prev || (c._score || 0) > (prev._score || 0)) map.set(id, c);
+ }
+ }
+ return [...map.values()].sort((a, b) => (b._score || 0) - (a._score || 0));
+}
+
+export async function handleRecommend({
+ tenantId,
+ text,
+ prev_context = {},
+ basket_items = [],
+ limit = 9,
+} = {}) {
+ const reco = prev_context?.reco && typeof prev_context.reco === "object" ? prev_context.reco : {};
+ const base_item = reco.base_item || pickBaseItem({ prev_context, basket_items });
+ const context_patch = { reco: { ...reco, base_item } };
+ const audit = { base_item, rules_used: [], queries: [] };
+
+ if (!base_item?.name) {
+ return {
+ reply: "¿Sobre qué producto querés recomendaciones?",
+ actions: [],
+ context_patch,
+ audit,
+ asked_slot: null,
+ candidates: [],
+ };
+ }
+
+ // PRIMERO: Inicializar slots y procesar respuesta pendiente ANTES de filtrar reglas
+ const slots = { ...(reco.slots || {}) };
+ let asked_slot = null;
+
+ // Procesar respuesta de slot pendiente PRIMERO
+ if (reco.awaiting_slot === "alcohol") {
+ const yn = parseYesNo(text);
+ if (yn != null) {
+ slots.alcohol = yn;
+ context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: null };
+ } else {
+ return {
+ reply: "¿Tomás alcohol?",
+ actions: [],
+ context_patch: { ...context_patch, reco: { ...context_patch.reco, awaiting_slot: "alcohol" } },
+ audit,
+ asked_slot: "alcohol",
+ candidates: [],
+ };
+ }
+ }
+
+ // DESPUÉS: Cargar y filtrar reglas CON SLOTS ACTUALIZADOS
+ const rulesRaw = await getRecoRules({ tenant_id: tenantId });
+ const rules = (rulesRaw || []).filter((r) => ruleMatchesBase({ rule: r, base_item, slots }));
+ audit.rules_used = rules.map((r) => ({ id: r.id, rule_key: r.rule_key, priority: r.priority }));
+
+ // Verificar si hay slots pendientes por preguntar
+ const askSlots = collectAskSlots(rules);
+ if (!context_patch.reco.awaiting_slot) {
+ const pending = askSlots.find((s) => s?.slot === "alcohol" && slots.alcohol == null);
+ if (pending) {
+ asked_slot = "alcohol";
+ context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: "alcohol" };
+ return {
+ reply: pending.question || "¿Tomás alcohol?",
+ actions: [],
+ context_patch,
+ audit,
+ asked_slot,
+ candidates: [],
+ };
+ }
+ }
+
+ const queries = collectQueries({ rules, slots });
+ audit.queries = queries;
+ const lists = [];
+ for (const q of queries.slice(0, 6)) {
+ const { candidates } = await retrieveCandidates({ tenantId, query: q, limit });
+ lists.push(candidates || []);
+ }
+ const merged = mergeCandidates({ lists, excludeId: base_item.product_id });
+
+ if (!merged.length) {
+ return {
+ reply: `No encontré recomendaciones para ${base_item.name}. ¿Querés que te sugiera algo distinto?`,
+ actions: [],
+ context_patch,
+ audit,
+ asked_slot: null,
+ candidates: [],
+ };
+ }
+
+ const { question, pending } = buildPagedOptions({ candidates: merged, pageSize: Math.min(9, limit) });
+ let reply = question;
+ if (process.env.RECO_WRITER === "1") {
+ const writer = await llmRecommendWriter({
+ base_item,
+ slots,
+ candidates: merged.slice(0, limit),
+ });
+ if (writer?.validation?.ok && writer.reply) {
+ reply = writer.reply;
+ }
+ audit.writer = {
+ ok: Boolean(writer?.validation?.ok),
+ model: writer?.model || null,
+ };
+ }
+ context_patch.pending_clarification = pending;
+ context_patch.pending_item = null;
+
+ return {
+ reply,
+ actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
+ context_patch,
+ audit,
+ asked_slot: null,
+ candidates: merged.slice(0, limit),
+ };
+}
diff --git a/src/modules/3-turn-engine/turnEngineV3.helpers.js b/src/modules/3-turn-engine/turnEngineV3.helpers.js
new file mode 100644
index 0000000..c1e5eb9
--- /dev/null
+++ b/src/modules/3-turn-engine/turnEngineV3.helpers.js
@@ -0,0 +1,16 @@
+export function askClarificationReply() {
+ return "Dale, ¿qué producto querés exactamente?";
+}
+
+export function shortSummary(history) {
+ if (!Array.isArray(history)) return "";
+ return history
+ .slice(-5)
+ .map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
+ .join(" | ");
+}
+
+export function hasAddress(ctx) {
+ return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
+}
+
diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js
index e09fec3..a215b00 100644
--- a/src/modules/3-turn-engine/turnEngineV3.js
+++ b/src/modules/3-turn-engine/turnEngineV3.js
@@ -1,6 +1,7 @@
import { llmNluV3 } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
import { safeNextState } from "./fsm.js";
+import { handleRecommend } from "./recommendations.js";
function unitAskFor(displayUnit) {
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
@@ -19,6 +20,12 @@ function inferDefaultUnit({ name, categories }) {
const cats = Array.isArray(categories) ? categories : [];
const hay = (re) =>
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
+ if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) {
+ return "unit";
+ }
+ if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) {
+ return "unit";
+ }
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
return "unit";
}
@@ -165,6 +172,25 @@ function resolveQuantity({ quantity, unit, displayUnit }) {
function buildPendingItemFromCandidate(candidate) {
const displayUnit = inferDefaultUnit({ name: candidate.name, categories: candidate.categories });
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H14",
+ location: "turnEngineV3.js:171",
+ message: "pending_item_display_unit",
+ data: {
+ name: candidate?.name || null,
+ categories: Array.isArray(candidate?.categories) ? candidate.categories.map((c) => c?.name || c) : [],
+ display_unit: displayUnit,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
return {
product_id: Number(candidate.woo_product_id),
variation_id: null,
@@ -192,6 +218,173 @@ function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
}
+/**
+ * Procesa múltiples items mencionados en un solo mensaje.
+ * Para cada item: busca en catálogo, si hay match fuerte + cantidad -> agrega al carrito.
+ * Si hay ambigüedad, para y crea pending_clarification para el primer item ambiguo.
+ */
+async function processMultiItems({
+ tenantId,
+ items,
+ prev_state,
+ prev_context,
+ audit,
+}) {
+ const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
+ const actions = [];
+ const context_patch = {};
+ const addedItems = [];
+ const addedLabels = [];
+ let prevItems = Array.isArray(prev?.order_basket?.items) ? [...prev.order_basket.items] : [];
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ const { candidates, audit: catAudit } = await retrieveCandidates({
+ tenantId,
+ query: item.product_query,
+ limit: 12,
+ });
+ audit.catalog_multi = audit.catalog_multi || [];
+ audit.catalog_multi.push({ query: item.product_query, count: candidates?.length || 0 });
+
+ if (!candidates.length) {
+ // No encontrado, seguimos con los demás
+ continue;
+ }
+
+ const best = candidates[0];
+ const second = candidates[1];
+ const strong = candidates.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
+
+ if (!strong) {
+ // Ambigüedad: crear pending_clarification para este item y guardar los restantes
+ const { question, pending } = buildPagedOptions({ candidates });
+ context_patch.pending_clarification = pending;
+ context_patch.pending_item = null;
+ // Guardar cantidad pendiente para este item
+ if (item.quantity != null) {
+ context_patch.pending_quantity = item.quantity;
+ context_patch.pending_unit = item.unit;
+ }
+ // Guardar items restantes para procesar después
+ const remainingItems = items.slice(i + 1);
+ if (remainingItems.length > 0) {
+ context_patch.pending_multi_items = remainingItems;
+ }
+ actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
+
+ // Si ya agregamos algunos items, incluirlos en el contexto
+ if (addedItems.length > 0) {
+ context_patch.order_basket = { items: prevItems };
+ }
+
+ const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
+
+ let reply = question;
+ if (addedLabels.length > 0) {
+ reply = `Anoté ${addedLabels.join(", ")}. Ahora, para "${item.product_query}":\n\n${question}`;
+ }
+
+ return {
+ plan: {
+ reply,
+ next_state,
+ intent: "add_to_cart",
+ missing_fields: ["product_selection"],
+ order_action: "none",
+ basket_resolved: { items: addedItems },
+ },
+ decision: { actions, context_patch, audit: { ...audit, fsm: v } },
+ };
+ }
+
+ // Match fuerte, verificar cantidad
+ const pendingItem = buildPendingItemFromCandidate(best);
+ const qty = resolveQuantity({
+ quantity: item.quantity,
+ unit: item.unit,
+ displayUnit: pendingItem.display_unit,
+ });
+
+ if (!qty?.quantity) {
+ // Sin cantidad: crear pending_item para este y guardar restantes
+ context_patch.pending_item = pendingItem;
+ const remainingItems = items.slice(i + 1);
+ if (remainingItems.length > 0) {
+ context_patch.pending_multi_items = remainingItems;
+ }
+ if (addedItems.length > 0) {
+ context_patch.order_basket = { items: prevItems };
+ }
+
+ const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
+
+ let reply = unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg");
+ if (addedLabels.length > 0) {
+ reply = `Anoté ${addedLabels.join(", ")}. Para ${pendingItem.name}, ${reply.toLowerCase()}`;
+ }
+
+ return {
+ plan: {
+ reply,
+ next_state,
+ intent: "add_to_cart",
+ missing_fields: ["quantity"],
+ order_action: "none",
+ basket_resolved: { items: addedItems },
+ },
+ decision: { actions, context_patch, audit: { ...audit, fsm: v } },
+ };
+ }
+
+ // Todo completo: agregar al carrito
+ const cartItem = {
+ product_id: pendingItem.product_id,
+ variation_id: pendingItem.variation_id,
+ quantity: qty.quantity,
+ unit: qty.unit,
+ label: pendingItem.name,
+ };
+ prevItems.push(cartItem);
+ addedItems.push(cartItem);
+ actions.push({ type: "add_to_cart", payload: cartItem });
+
+ const display = qty.display_unit === "kg"
+ ? `${qty.display_quantity}kg de ${pendingItem.name}`
+ : qty.display_unit === "unit"
+ ? `${qty.display_quantity} ${pendingItem.name}`
+ : `${qty.display_quantity}g de ${pendingItem.name}`;
+ addedLabels.push(display);
+ }
+
+ // Todos los items procesados exitosamente
+ if (addedItems.length > 0) {
+ context_patch.order_basket = { items: prevItems };
+ context_patch.pending_item = null;
+ context_patch.pending_clarification = null;
+ context_patch.pending_quantity = null;
+ context_patch.pending_unit = null;
+ context_patch.pending_multi_items = null;
+
+ const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
+
+ return {
+ plan: {
+ reply: `Perfecto, anoto ${addedLabels.join(" y ")}. ¿Algo más?`,
+ next_state,
+ intent: "add_to_cart",
+ missing_fields: [],
+ order_action: "none",
+ basket_resolved: { items: addedItems },
+ },
+ decision: { actions, context_patch, audit: { ...audit, fsm: v } },
+ };
+ }
+
+ // Ningún item encontrado
+ return null;
+}
+
export async function runTurnV3({
tenantId,
chat_id,
@@ -206,6 +399,12 @@ export async function runTurnV3({
const context_patch = {};
const audit = {};
+ // Observabilidad (NO se envía al LLM)
+ audit.trace = {
+ tenantId: tenantId || null,
+ chat_id: chat_id || null,
+ };
+
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 }))
: [];
@@ -221,13 +420,100 @@ export async function runTurnV3({
last_shown_options,
locale: tenant_config?.locale || "es-AR",
};
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H6",
+ location: "turnEngineV3.js:231",
+ message: "nlu_input_built",
+ data: {
+ text_len: String(nluInput.last_user_message || "").length,
+ state: nluInput.conversation_state || null,
+ memory_len: String(nluInput.memory_summary || "").length,
+ pending_clarification: Boolean(nluInput.pending_context?.pending_clarification),
+ pending_item: Boolean(nluInput.pending_context?.pending_item),
+ last_shown_options: Array.isArray(nluInput.last_shown_options)
+ ? nluInput.last_shown_options.length
+ : null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H5",
+ location: "turnEngineV3.js:235",
+ message: "nlu_result",
+ data: {
+ intent: nlu?.intent || null,
+ needsCatalog: Boolean(nlu?.needs?.catalog_lookup),
+ has_pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
+ has_pending_item: Boolean(prev?.pending_item?.product_id),
+ nlu_valid: validation?.ok ?? null,
+ raw_len: typeof raw_text === "string" ? raw_text.length : null,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+
+ // 0) Procesar multi-items si hay varios productos en un mensaje
+ // Solo si no hay pending_clarification ni pending_item (flujo limpio)
+ if (
+ Array.isArray(nlu?.entities?.items) &&
+ nlu.entities.items.length > 0 &&
+ !prev?.pending_clarification?.candidates?.length &&
+ !prev?.pending_item?.product_id
+ ) {
+ const multiResult = await processMultiItems({
+ tenantId,
+ items: nlu.entities.items,
+ prev_state,
+ prev_context: prev,
+ audit,
+ });
+ if (multiResult) {
+ return multiResult;
+ }
+ // Si multiResult es null, ningún item fue encontrado, seguir con flujo normal
+ }
// 1) Resolver pending_clarification primero
if (prev?.pending_clarification?.candidates?.length) {
const resolved = resolvePendingSelection({ text, nlu, pending: prev.pending_clarification });
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H12",
+ location: "turnEngineV3.js:239",
+ message: "pending_clarification_resolved",
+ data: {
+ kind: resolved?.kind || null,
+ selection_type: nlu?.entities?.selection?.type || null,
+ selection_value: nlu?.entities?.selection?.value || null,
+ text_len: String(text || "").length,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
if (resolved.kind === "more") {
const nextPending = resolved.pending || prev.pending_clarification;
const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question;
@@ -249,9 +535,10 @@ export async function runTurnV3({
}
if (resolved.kind === "chosen" && resolved.chosen) {
const pendingItem = buildPendingItemFromCandidate(resolved.chosen);
+ // Usar cantidad guardada como fallback si el NLU actual no la tiene
const qty = resolveQuantity({
- quantity: nlu?.entities?.quantity,
- unit: nlu?.entities?.unit,
+ quantity: nlu?.entities?.quantity ?? prev?.pending_quantity,
+ unit: nlu?.entities?.unit ?? prev?.pending_unit,
displayUnit: pendingItem.display_unit,
});
if (qty?.quantity) {
@@ -266,7 +553,34 @@ export async function runTurnV3({
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
context_patch.pending_clarification = null;
+ context_patch.pending_quantity = null;
+ context_patch.pending_unit = null;
actions.push({ type: "add_to_cart", payload: item });
+
+ // Procesar pending_multi_items si hay
+ const pendingMulti = Array.isArray(prev?.pending_multi_items) ? prev.pending_multi_items : [];
+ if (pendingMulti.length > 0) {
+ context_patch.pending_multi_items = null;
+ const multiResult = await processMultiItems({
+ tenantId,
+ items: pendingMulti,
+ prev_state,
+ prev_context: { ...prev, ...context_patch },
+ audit,
+ });
+ if (multiResult) {
+ // Combinar resultados
+ const display = qty.display_unit === "kg"
+ ? `${qty.display_quantity}kg de ${pendingItem.name}`
+ : qty.display_unit === "unit"
+ ? `${qty.display_quantity} ${pendingItem.name}`
+ : `${qty.display_quantity}g de ${pendingItem.name}`;
+ multiResult.plan.reply = `Anoté ${display}. ${multiResult.plan.reply}`;
+ multiResult.decision.actions = [...actions, ...multiResult.decision.actions];
+ return multiResult;
+ }
+ }
+
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
@@ -287,6 +601,7 @@ export async function runTurnV3({
}
context_patch.pending_item = pendingItem;
context_patch.pending_clarification = null;
+ // Preservar pending_quantity si había, se usará cuando el usuario dé cantidad
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return {
plan: {
@@ -320,11 +635,32 @@ export async function runTurnV3({
// 2) Si hay pending_item, esperamos cantidad
if (prev?.pending_item?.product_id) {
const pendingItem = prev.pending_item;
+ // Usar cantidad guardada como fallback si el NLU actual no la tiene
const qty = resolveQuantity({
- quantity: nlu?.entities?.quantity,
- unit: nlu?.entities?.unit,
+ quantity: nlu?.entities?.quantity ?? prev?.pending_quantity,
+ unit: nlu?.entities?.unit ?? prev?.pending_unit,
displayUnit: pendingItem.display_unit || "kg",
});
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H12",
+ location: "turnEngineV3.js:332",
+ message: "pending_item_quantity",
+ data: {
+ quantity_in: nlu?.entities?.quantity ?? null,
+ unit_in: nlu?.entities?.unit ?? null,
+ qty_resolved: qty?.quantity ?? null,
+ text: String(text || "").slice(0, 20),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
if (qty?.quantity) {
const item = {
product_id: Number(pendingItem.product_id),
@@ -336,7 +672,34 @@ export async function runTurnV3({
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null;
+ context_patch.pending_quantity = null;
+ context_patch.pending_unit = null;
actions.push({ type: "add_to_cart", payload: item });
+
+ // Procesar pending_multi_items si hay
+ const pendingMulti = Array.isArray(prev?.pending_multi_items) ? prev.pending_multi_items : [];
+ if (pendingMulti.length > 0) {
+ context_patch.pending_multi_items = null;
+ const multiResult = await processMultiItems({
+ tenantId,
+ items: pendingMulti,
+ prev_state,
+ prev_context: { ...prev, ...context_patch },
+ audit,
+ });
+ if (multiResult) {
+ // Combinar resultados
+ const display = qty.display_unit === "kg"
+ ? `${qty.display_quantity}kg de ${item.label}`
+ : qty.display_unit === "unit"
+ ? `${qty.display_quantity} ${item.label}`
+ : `${qty.display_quantity}g de ${item.label}`;
+ multiResult.plan.reply = `Anoté ${display}. ${multiResult.plan.reply}`;
+ multiResult.decision.actions = [...actions, ...multiResult.decision.actions];
+ return multiResult;
+ }
+ }
+
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg`
@@ -371,8 +734,71 @@ export async function runTurnV3({
// 3) Intento normal
const intent = nlu?.intent || "other";
- const productQuery = String(nlu?.entities?.product_query || "").trim();
+ let productQuery = String(nlu?.entities?.product_query || "").trim();
const needsCatalog = Boolean(nlu?.needs?.catalog_lookup);
+ const lastBasketItem = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items.slice(-1)[0] : null;
+ const fallbackQuery =
+ !productQuery && intent === "browse"
+ ? (prev?.pending_item?.name || lastBasketItem?.label || lastBasketItem?.name || null)
+ : null;
+ if (fallbackQuery) {
+ productQuery = String(fallbackQuery).trim();
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H13",
+ location: "turnEngineV3.js:390",
+ message: "browse_fallback_query",
+ data: {
+ fallback: productQuery,
+ has_basket: Boolean(lastBasketItem),
+ has_pending_item: Boolean(prev?.pending_item?.name),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+ }
+
+ if (intent === "recommend") {
+ const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
+ const rec = await handleRecommend({
+ tenantId,
+ text,
+ prev_context: prev,
+ basket_items: basketItems,
+ });
+ if (rec?.actions?.length) actions.push(...rec.actions);
+ if (rec?.context_patch) Object.assign(context_patch, rec.context_patch);
+ if (rec?.audit) audit.recommend = rec.audit;
+ const didShowOptions = actions.some((a) => a?.type === "show_options");
+ const { next_state, validation: v } = safeNextState(
+ prev_state,
+ { ...prev, ...context_patch },
+ { did_show_options: didShowOptions, is_browsing: didShowOptions }
+ );
+ const missing_fields = [];
+ if (rec?.asked_slot) missing_fields.push(rec.asked_slot);
+ if (didShowOptions) missing_fields.push("product_selection");
+ if (!rec?.asked_slot && !didShowOptions && !basketItems.length && !rec?.candidates?.length) {
+ missing_fields.push("recommend_base");
+ }
+ return {
+ plan: {
+ reply: rec?.reply || "¿Qué te gustaría que te recomiende?",
+ next_state,
+ intent: "recommend",
+ missing_fields,
+ order_action: "none",
+ basket_resolved: { items: basketItems },
+ },
+ decision: { actions, context_patch, audit: { ...audit, fsm: v } },
+ };
+ }
if (intent === "greeting") {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
@@ -484,6 +910,11 @@ export async function runTurnV3({
const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
+ // Guardar cantidad pendiente para usarla después de la selección
+ if (nlu?.entities?.quantity != null) {
+ context_patch.pending_quantity = nlu.entities.quantity;
+ context_patch.pending_unit = nlu.entities.unit;
+ }
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return {
diff --git a/src/modules/3-turn-engine/turnEngineV3.pendingSelection.js b/src/modules/3-turn-engine/turnEngineV3.pendingSelection.js
new file mode 100644
index 0000000..a80233d
--- /dev/null
+++ b/src/modules/3-turn-engine/turnEngineV3.pendingSelection.js
@@ -0,0 +1,112 @@
+function parseIndexSelection(text) {
+ const t = String(text || "").toLowerCase();
+ const m = /\b(\d{1,2})\b/.exec(t);
+ if (m) return parseInt(m[1], 10);
+ if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
+ if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
+ if (/\btercera\b|\btercero\b/.test(t)) return 3;
+ if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
+ if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
+ if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
+ if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
+ if (/\boctava\b|\boctavo\b/.test(t)) return 8;
+ if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
+ if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
+ return null;
+}
+
+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 normalizeText(s) {
+ return String(s || "")
+ .toLowerCase()
+ .replace(/[¿?¡!.,;:()"]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function scoreTextMatch(query, candidateName) {
+ const qt = new Set(normalizeText(query).split(" ").filter(Boolean));
+ const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean));
+ let hits = 0;
+ for (const w of qt) if (nt.has(w)) hits++;
+ return hits / Math.max(qt.size, 1);
+}
+
+export 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 options = slice.map((c, i) => ({
+ idx: baseIdx + i,
+ type: "product",
+ woo_product_id: c.woo_product_id,
+ name: c.name,
+ }));
+ 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}`))
+ .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 };
+}
+
+export function resolvePendingSelection({ text, nlu, pending }) {
+ if (!pending?.candidates?.length) return { kind: "none" };
+
+ if (isShowMoreRequest(text)) {
+ const { question, pending: nextPending } = buildPagedOptions({
+ candidates: pending.candidates,
+ candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)),
+ baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1),
+ pageSize: pending.page_size || 9,
+ });
+ return { kind: "more", question, pending: nextPending };
+ }
+
+ const idx =
+ (nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
+ parseIndexSelection(text);
+ if (idx && Array.isArray(pending.options)) {
+ const opt = pending.options.find((o) => o.idx === idx);
+ if (opt?.type === "more") return { kind: "more", question: null, pending };
+ if (opt?.woo_product_id) {
+ const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
+ if (chosen) return { kind: "chosen", chosen };
+ }
+ }
+
+ const selText = nlu?.entities?.selection?.type === "text" ? String(nlu.entities.selection.value || "").trim() : null;
+ const q = selText || nlu?.entities?.product_query || null;
+ if (q) {
+ const scored = pending.candidates
+ .map((c) => ({ c, s: scoreTextMatch(q, c?.name) }))
+ .sort((a, b) => b.s - a.s);
+ if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) {
+ return { kind: "chosen", chosen: scored[0].c };
+ }
+ }
+
+ return { kind: "ask" };
+}
+
diff --git a/src/modules/3-turn-engine/turnEngineV3.units.js b/src/modules/3-turn-engine/turnEngineV3.units.js
new file mode 100644
index 0000000..95feff2
--- /dev/null
+++ b/src/modules/3-turn-engine/turnEngineV3.units.js
@@ -0,0 +1,51 @@
+export function unitAskFor(displayUnit) {
+ if (displayUnit === "unit") return "¿Cuántas unidades querés?";
+ if (displayUnit === "g") return "¿Cuántos gramos querés?";
+ return "¿Cuántos kilos querés?";
+}
+
+export function unitDisplay(unit) {
+ if (unit === "unit") return "unidades";
+ if (unit === "g") return "gramos";
+ return "kilos";
+}
+
+export function inferDefaultUnit({ name, categories }) {
+ const n = String(name || "").toLowerCase();
+ const cats = Array.isArray(categories) ? categories : [];
+ const hay = (re) =>
+ cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
+ if (
+ hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)
+ ) {
+ return "unit";
+ }
+ return "kg";
+}
+
+export function normalizeUnit(unit) {
+ if (!unit) return null;
+ const u = String(unit).toLowerCase();
+ if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
+ if (u === "g" || u === "gramo" || u === "gramos") return "g";
+ if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
+ return null;
+}
+
+export function resolveQuantity({ quantity, unit, displayUnit }) {
+ if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null;
+ const q = Number(quantity);
+ const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg");
+ if (u === "unit") {
+ return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" };
+ }
+ if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" };
+ // kg -> gramos enteros
+ return {
+ quantity: Math.round(q * 1000),
+ unit: "g",
+ display_unit: "kg",
+ display_quantity: q,
+ };
+}
+
diff --git a/src/modules/shared/wooSnapshot.js b/src/modules/shared/wooSnapshot.js
index 367f216..d421fff 100644
--- a/src/modules/shared/wooSnapshot.js
+++ b/src/modules/shared/wooSnapshot.js
@@ -146,6 +146,35 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
const query = String(q || "").trim();
if (!query) return { items: [], source: "snapshot" };
const like = `%${query}%`;
+ // #region agent log
+ const totalSnapshot = await pool.query(
+ "select count(*)::int as cnt from woo_products_snapshot where tenant_id=$1",
+ [tenantId]
+ );
+ const totalSellable = await pool.query(
+ "select count(*)::int as cnt from sellable_items where tenant_id=$1",
+ [tenantId]
+ );
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H8",
+ location: "wooSnapshot.js:152",
+ message: "snapshot_counts",
+ data: {
+ tenantId: tenantId || null,
+ total_snapshot: totalSnapshot?.rows?.[0]?.cnt ?? null,
+ total_sellable: totalSellable?.rows?.[0]?.cnt ?? null,
+ query,
+ limit: lim,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
const sql = `
select *
from sellable_items
@@ -155,6 +184,25 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
limit $3
`;
const { rows } = await pool.query(sql, [tenantId, like, lim]);
+ // #region agent log
+ fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionId: "debug-session",
+ runId: "pre-fix",
+ hypothesisId: "H8",
+ location: "wooSnapshot.js:168",
+ message: "snapshot_search_result",
+ data: {
+ query,
+ found: rows.length,
+ sample_names: rows.slice(0, 3).map((r) => r?.name).filter(Boolean),
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
return { items: rows.map(snapshotRowToItem), source: "snapshot" };
}