ux improved
This commit is contained in:
68
src/modules/0-ui/controllers/aliases.js
Normal file
68
src/modules/0-ui/controllers/aliases.js
Normal 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" });
|
||||
}
|
||||
};
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
88
src/modules/0-ui/controllers/recommendations.js
Normal file
88
src/modules/0-ui/controllers/recommendations.js
Normal 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
296
src/modules/0-ui/db/repo.js
Normal 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;
|
||||
}
|
||||
19
src/modules/0-ui/handlers/aliases.js
Normal file
19
src/modules/0-ui/handlers/aliases.js
Normal 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 };
|
||||
}
|
||||
@@ -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)" };
|
||||
}
|
||||
|
||||
|
||||
47
src/modules/0-ui/handlers/recommendations.js
Normal file
47
src/modules/0-ui/handlers/recommendations.js
Normal 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 };
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
217
src/modules/3-turn-engine/recommendations.js
Normal file
217
src/modules/3-turn-engine/recommendations.js
Normal 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),
|
||||
};
|
||||
}
|
||||
16
src/modules/3-turn-engine/turnEngineV3.helpers.js
Normal file
16
src/modules/3-turn-engine/turnEngineV3.helpers.js
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
112
src/modules/3-turn-engine/turnEngineV3.pendingSelection.js
Normal file
112
src/modules/3-turn-engine/turnEngineV3.pendingSelection.js
Normal 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" };
|
||||
}
|
||||
|
||||
51
src/modules/3-turn-engine/turnEngineV3.units.js
Normal file
51
src/modules/3-turn-engine/turnEngineV3.units.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user