ux improved

This commit is contained in:
Lucas Tettamanti
2026-01-17 04:13:35 -03:00
parent 98e3d78e3d
commit 63b9ecef61
35 changed files with 4266 additions and 75 deletions

View File

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

View File

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

View File

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

296
src/modules/0-ui/db/repo.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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