mejoras en el modelo de clarificacion de productos
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts } from "../handlers/products.js";
|
||||
import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts, handleUpdateProductUnit, handleBulkUpdateProductUnit, handleUpdateProduct } from "../handlers/products.js";
|
||||
|
||||
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
@@ -54,3 +54,60 @@ export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeUpdateProductUnit = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const wooProductId = req.params.id;
|
||||
const { sell_unit } = req.body || {};
|
||||
|
||||
if (!sell_unit || !["kg", "unit"].includes(sell_unit)) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_sell_unit" });
|
||||
}
|
||||
|
||||
const result = await handleUpdateProductUnit({ tenantId, wooProductId, sell_unit });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeBulkUpdateProductUnit = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const { woo_product_ids, sell_unit } = req.body || {};
|
||||
|
||||
if (!sell_unit || !["kg", "unit"].includes(sell_unit)) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_sell_unit" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(woo_product_ids) || !woo_product_ids.length) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_woo_product_ids" });
|
||||
}
|
||||
|
||||
const result = await handleBulkUpdateProductUnit({ tenantId, wooProductIds: woo_product_ids, sell_unit });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
export const makeUpdateProduct = (tenantIdOrFn) => async (req, res) => {
|
||||
try {
|
||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||
const wooProductId = req.params.id;
|
||||
const { sell_unit, categories } = req.body || {};
|
||||
|
||||
if (sell_unit && !["kg", "unit"].includes(sell_unit)) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_sell_unit" });
|
||||
}
|
||||
|
||||
const result = await handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ ok: false, error: "internal_error" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
|
||||
categories,
|
||||
attributes_normalized,
|
||||
updated_at as refreshed_at,
|
||||
raw as payload
|
||||
raw as payload,
|
||||
raw->>'_sell_unit_override' as sell_unit
|
||||
from woo_products_snapshot
|
||||
where tenant_id = $1
|
||||
and (name ilike $2 or coalesce(slug,'') ilike $2)
|
||||
@@ -41,7 +42,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
|
||||
categories,
|
||||
attributes_normalized,
|
||||
updated_at as refreshed_at,
|
||||
raw as payload
|
||||
raw as payload,
|
||||
raw->>'_sell_unit_override' as sell_unit
|
||||
from woo_products_snapshot
|
||||
where tenant_id = $1
|
||||
order by name asc
|
||||
@@ -65,7 +67,8 @@ export async function getProductByWooId({ tenantId, wooProductId }) {
|
||||
categories,
|
||||
attributes_normalized,
|
||||
updated_at as refreshed_at,
|
||||
raw as payload
|
||||
raw as payload,
|
||||
raw->>'_sell_unit_override' as sell_unit
|
||||
from woo_products_snapshot
|
||||
where tenant_id = $1 and woo_id = $2
|
||||
limit 1
|
||||
@@ -74,6 +77,66 @@ export async function getProductByWooId({ tenantId, wooProductId }) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function updateProductSellUnit({ tenantId, wooProductId, sell_unit }) {
|
||||
const sql = `
|
||||
update woo_products_snapshot
|
||||
set raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $3::jsonb)
|
||||
where tenant_id = $1 and woo_id = $2
|
||||
returning woo_id as woo_product_id
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, wooProductId, JSON.stringify(sell_unit)]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_unit }) {
|
||||
if (!wooProductIds || !wooProductIds.length) return { updated: 0 };
|
||||
|
||||
const sql = `
|
||||
update woo_products_snapshot
|
||||
set raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $3::jsonb)
|
||||
where tenant_id = $1 and woo_id = ANY($2::int[])
|
||||
`;
|
||||
const result = await pool.query(sql, [tenantId, wooProductIds, JSON.stringify(sell_unit)]);
|
||||
return { updated: result.rowCount };
|
||||
}
|
||||
|
||||
export async function updateProduct({ tenantId, wooProductId, sell_unit, categories }) {
|
||||
// Build the JSONB update dynamically
|
||||
let updates = [];
|
||||
let params = [tenantId, wooProductId];
|
||||
let paramIdx = 3;
|
||||
|
||||
if (sell_unit) {
|
||||
updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $${paramIdx}::jsonb)`);
|
||||
params.push(JSON.stringify(sell_unit));
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (categories) {
|
||||
// Also update the categories column if it exists
|
||||
updates.push(`categories = $${paramIdx}::jsonb`);
|
||||
params.push(JSON.stringify(categories.map(name => ({ name }))));
|
||||
paramIdx++;
|
||||
|
||||
// Also store in raw for persistence
|
||||
updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_categories_override}', $${paramIdx}::jsonb)`);
|
||||
params.push(JSON.stringify(categories));
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (!updates.length) return null;
|
||||
|
||||
const sql = `
|
||||
update woo_products_snapshot
|
||||
set ${updates.join(", ")}
|
||||
where tenant_id = $1 and woo_id = $2
|
||||
returning woo_id as woo_product_id
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Aliases
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -188,7 +251,8 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
|
||||
if (query) {
|
||||
const like = `%${query}%`;
|
||||
sql = `
|
||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
||||
trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
from product_reco_rules
|
||||
where tenant_id = $1 and rule_key ilike $2
|
||||
order by priority desc, rule_key asc
|
||||
@@ -197,7 +261,8 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
|
||||
params = [tenantId, like, lim];
|
||||
} else {
|
||||
sql = `
|
||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
||||
trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
from product_reco_rules
|
||||
where tenant_id = $1
|
||||
order by priority desc, rule_key asc
|
||||
@@ -212,7 +277,8 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
|
||||
|
||||
export async function getRecommendationById({ tenantId, id }) {
|
||||
const sql = `
|
||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
||||
trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
from product_reco_rules
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
@@ -230,11 +296,13 @@ export async function insertRecommendation({
|
||||
ask_slots = [],
|
||||
active = true,
|
||||
priority = 100,
|
||||
trigger_product_ids = [],
|
||||
recommended_product_ids = [],
|
||||
}) {
|
||||
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
|
||||
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, [
|
||||
@@ -246,6 +314,8 @@ export async function insertRecommendation({
|
||||
JSON.stringify(ask_slots || []),
|
||||
active !== false,
|
||||
priority || 100,
|
||||
trigger_product_ids || [],
|
||||
recommended_product_ids || [],
|
||||
]);
|
||||
|
||||
return rows[0];
|
||||
@@ -260,6 +330,8 @@ export async function updateRecommendation({
|
||||
ask_slots,
|
||||
active,
|
||||
priority,
|
||||
trigger_product_ids,
|
||||
recommended_product_ids,
|
||||
}) {
|
||||
const sql = `
|
||||
update product_reco_rules
|
||||
@@ -270,9 +342,11 @@ export async function updateRecommendation({
|
||||
ask_slots = $6,
|
||||
active = $7,
|
||||
priority = $8,
|
||||
trigger_product_ids = $9,
|
||||
recommended_product_ids = $10,
|
||||
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
|
||||
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, [
|
||||
@@ -284,6 +358,8 @@ export async function updateRecommendation({
|
||||
JSON.stringify(ask_slots || []),
|
||||
active !== false,
|
||||
priority || 100,
|
||||
trigger_product_ids || [],
|
||||
recommended_product_ids || [],
|
||||
]);
|
||||
|
||||
return rows[0] || null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
|
||||
import { listProducts, getProductByWooId } from "../db/repo.js";
|
||||
import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct } from "../db/repo.js";
|
||||
|
||||
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
|
||||
const { items, source } = await searchSnapshotItems({
|
||||
@@ -25,3 +25,18 @@ export async function handleSyncProducts({ tenantId }) {
|
||||
return { ok: true, message: "Sync triggered (use import script for full sync)" };
|
||||
}
|
||||
|
||||
export async function handleUpdateProductUnit({ tenantId, wooProductId, sell_unit }) {
|
||||
await updateProductSellUnit({ tenantId, wooProductId, sell_unit });
|
||||
return { ok: true, woo_product_id: wooProductId, sell_unit };
|
||||
}
|
||||
|
||||
export async function handleBulkUpdateProductUnit({ tenantId, wooProductIds, sell_unit }) {
|
||||
await bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_unit });
|
||||
return { ok: true, updated_count: wooProductIds.length, sell_unit };
|
||||
}
|
||||
|
||||
export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories }) {
|
||||
await updateProduct({ tenantId, wooProductId, sell_unit, categories });
|
||||
return { ok: true, woo_product_id: wooProductId, sell_unit, categories };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ export async function handleCreateRecommendation({
|
||||
ask_slots = [],
|
||||
active = true,
|
||||
priority = 100,
|
||||
trigger_product_ids = [],
|
||||
recommended_product_ids = [],
|
||||
}) {
|
||||
return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority });
|
||||
return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids });
|
||||
}
|
||||
|
||||
export async function handleUpdateRecommendation({
|
||||
@@ -37,8 +39,10 @@ export async function handleUpdateRecommendation({
|
||||
ask_slots,
|
||||
active,
|
||||
priority,
|
||||
trigger_product_ids,
|
||||
recommended_product_ids,
|
||||
}) {
|
||||
return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority });
|
||||
return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids });
|
||||
}
|
||||
|
||||
export async function handleDeleteRecommendation({ tenantId, id }) {
|
||||
|
||||
@@ -6,27 +6,7 @@ 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) {
|
||||
if (!parsed.ok) {
|
||||
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ 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, makeListProducts, makeGetProduct, makeSyncProducts } from "../../0-ui/controllers/products.js";
|
||||
import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } 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";
|
||||
@@ -53,8 +53,11 @@ export function createSimulatorRouter({ tenantId }) {
|
||||
router.get("/messages", makeListMessages(getTenantId));
|
||||
router.get("/products", makeListProducts(getTenantId));
|
||||
router.get("/products/search", makeSearchProducts(getTenantId));
|
||||
router.get("/products/:id", makeGetProduct(getTenantId));
|
||||
router.patch("/products/bulk/unit", makeBulkUpdateProductUnit(getTenantId));
|
||||
router.post("/products/sync", makeSyncProducts(getTenantId));
|
||||
router.get("/products/:id", makeGetProduct(getTenantId));
|
||||
router.patch("/products/:id/unit", makeUpdateProductUnit(getTenantId));
|
||||
router.patch("/products/:id", makeUpdateProduct(getTenantId));
|
||||
|
||||
router.get("/aliases", makeListAliases(getTenantId));
|
||||
router.post("/aliases", makeCreateAlias(getTenantId));
|
||||
|
||||
@@ -565,7 +565,8 @@ 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
|
||||
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
||||
trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
from product_reco_rules
|
||||
where tenant_id=$1 and active=true
|
||||
order by priority asc, id asc
|
||||
@@ -574,9 +575,26 @@ export async function getRecoRules({ tenant_id }) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar reglas que tengan alguno de los productos como trigger.
|
||||
*/
|
||||
export async function getRecoRulesByProductIds({ tenant_id, product_ids = [] }) {
|
||||
if (!product_ids?.length) return [];
|
||||
const sql = `
|
||||
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
||||
trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
from product_reco_rules
|
||||
where tenant_id=$1 and active=true and trigger_product_ids && $2::int[]
|
||||
order by priority asc, id asc
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenant_id, product_ids]);
|
||||
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
|
||||
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority,
|
||||
trigger_product_ids, recommended_product_ids, created_at, updated_at
|
||||
from product_reco_rules
|
||||
where tenant_id=$1 and rule_key=$2
|
||||
limit 1
|
||||
|
||||
@@ -123,28 +123,7 @@ 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
|
||||
|
||||
const prev = 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;
|
||||
@@ -153,27 +132,7 @@ export async function processMessage({
|
||||
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({
|
||||
let externalCustomerId = await getExternalCustomerIdByChat({
|
||||
tenant_id: tenantId,
|
||||
wa_chat_id: chat_id,
|
||||
provider: "woo",
|
||||
@@ -203,6 +162,9 @@ export async function processMessage({
|
||||
logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state });
|
||||
|
||||
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||
// #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({location:"pipeline.js:164",message:"pipeline_loaded_context",data:{prev_state,has_prev_context:!!prev?.context,reducedContext_has_order_basket:!!reducedContext?.order_basket,reducedContext_basket_count:reducedContext?.order_basket?.items?.length||0,reducedContext_basket_labels:(reducedContext?.order_basket?.items||[]).map(i=>i.label)},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H3-H4"})}).catch(()=>{});
|
||||
// #endregion
|
||||
let decision;
|
||||
let plan;
|
||||
let llmMeta;
|
||||
@@ -222,28 +184,7 @@ 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 runStatus = llmMeta?.error ? "warn" : "ok";
|
||||
const isSimulated = provider === "sim" || meta?.source === "sim";
|
||||
|
||||
const invariants = {
|
||||
@@ -401,6 +342,10 @@ export async function processMessage({
|
||||
woo_customer_error: wooCustomerError,
|
||||
};
|
||||
|
||||
// #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({location:"pipeline.js:345",message:"pipeline_saving_context",data:{reducedContext_basket_count:reducedContext?.order_basket?.items?.length||0,context_patch_basket_count:decision?.context_patch?.order_basket?.items?.length||0,final_context_basket_count:context?.order_basket?.items?.length||0,final_context_basket_labels:(context?.order_basket?.items||[]).map(i=>i.label),plan_intent:plan?.intent},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H2-H4"})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state;
|
||||
plan.next_state = nextState;
|
||||
|
||||
@@ -429,6 +374,9 @@ export async function processMessage({
|
||||
await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms });
|
||||
}
|
||||
|
||||
// Incluir carrito completo para la UI
|
||||
const fullBasket = context?.order_basket?.items || [];
|
||||
|
||||
sseSend("run.created", {
|
||||
run_id,
|
||||
ts: nowIso(),
|
||||
@@ -437,7 +385,7 @@ export async function processMessage({
|
||||
status: runStatus,
|
||||
prev_state,
|
||||
input: { text },
|
||||
llm_output: { ...plan, _llm: llmMeta },
|
||||
llm_output: { ...plan, _llm: llmMeta, full_basket: { items: fullBasket } },
|
||||
tools,
|
||||
invariants,
|
||||
final_reply: plan.reply,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import OpenAI from "openai";
|
||||
import { debug as dbg } from "../shared/debug.js";
|
||||
import { searchSnapshotItems } from "../shared/wooSnapshot.js";
|
||||
import { searchSnapshotItems, getSnapshotItemsByIds } from "../shared/wooSnapshot.js";
|
||||
import {
|
||||
searchProductAliases,
|
||||
getProductEmbedding,
|
||||
@@ -137,48 +137,53 @@ export async function retrieveCandidates({
|
||||
|
||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||
|
||||
// 1) Buscar aliases que matcheen la query
|
||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
||||
const aliasBoostByProduct = new Map();
|
||||
const aliasProductIds = new Set();
|
||||
for (const a of aliases) {
|
||||
if (a?.woo_product_id) {
|
||||
const id = Number(a.woo_product_id);
|
||||
const boost = Number(a.boost || 0);
|
||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
||||
aliasProductIds.add(id);
|
||||
}
|
||||
}
|
||||
audit.sources.aliases = aliases.length;
|
||||
|
||||
// 2) Buscar productos por nombre/slug (búsqueda literal)
|
||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||
tenantId,
|
||||
q,
|
||||
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) => {
|
||||
|
||||
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
|
||||
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
|
||||
const missingAliasIds = [...aliasProductIds].filter(id => !foundIds.has(id));
|
||||
let aliasItems = [];
|
||||
if (missingAliasIds.length > 0) {
|
||||
const { items: fromAlias } = await getSnapshotItemsByIds({
|
||||
tenantId,
|
||||
wooProductIds: missingAliasIds,
|
||||
});
|
||||
aliasItems = fromAlias || [];
|
||||
audit.sources.alias_products = aliasItems.length;
|
||||
}
|
||||
// 4) Combinar productos de búsqueda literal + productos de aliases
|
||||
const allItems = [...(wooItems || []), ...aliasItems];
|
||||
|
||||
let candidates = allItems.map((c) => {
|
||||
const lit = literalScore(q, c);
|
||||
const boost = aliasBoostByProduct.get(Number(c.woo_product_id)) || 0;
|
||||
return { ...c, _score: lit + boost, _score_detail: { literal: lit, alias_boost: boost } };
|
||||
// Productos encontrados solo por alias tienen lit=0 pero boost alto
|
||||
const finalScore = lit + boost + (aliasProductIds.has(Number(c.woo_product_id)) && lit < 0.3 ? 0.5 : 0);
|
||||
return {
|
||||
...c,
|
||||
_score: finalScore,
|
||||
_score_detail: { literal: lit, alias_boost: boost, from_alias: aliasProductIds.has(Number(c.woo_product_id)) }
|
||||
};
|
||||
});
|
||||
|
||||
// embeddings: opcional, si hay key y tenemos candidatos
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
export const ConversationState = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
BROWSING: "BROWSING",
|
||||
CLARIFYING_ITEMS: "CLARIFYING_ITEMS", // Clarificando items pendientes uno por uno
|
||||
AWAITING_QUANTITY: "AWAITING_QUANTITY",
|
||||
CART_ACTIVE: "CART_ACTIVE",
|
||||
CLARIFYING_PAYMENT: "CLARIFYING_PAYMENT", // Preguntando método de pago (efectivo/link)
|
||||
CLARIFYING_SHIPPING: "CLARIFYING_SHIPPING", // Preguntando delivery o retiro
|
||||
AWAITING_ADDRESS: "AWAITING_ADDRESS",
|
||||
AWAITING_PAYMENT: "AWAITING_PAYMENT",
|
||||
COMPLETED: "COMPLETED",
|
||||
@@ -34,6 +37,16 @@ function hasPendingItem(ctx) {
|
||||
return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si hay items pendientes de clarificar (nuevo modelo acumulativo).
|
||||
* Un item pendiente tiene status "needs_type" o "needs_quantity".
|
||||
*/
|
||||
function hasPendingItems(ctx) {
|
||||
const items = ctx?.pending_items;
|
||||
if (!Array.isArray(items) || items.length === 0) return false;
|
||||
return items.some(i => i.status === "needs_type" || i.status === "needs_quantity");
|
||||
}
|
||||
|
||||
function hasAddress(ctx) {
|
||||
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
||||
}
|
||||
@@ -55,6 +68,34 @@ function isPaid(ctx) {
|
||||
return st === "approved" || st === "paid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si estamos clarificando método de pago.
|
||||
*/
|
||||
function isClarifyingPayment(ctx) {
|
||||
return ctx?.checkout_step === "payment_method";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si estamos clarificando shipping (delivery/retiro).
|
||||
*/
|
||||
function isClarifyingShipping(ctx) {
|
||||
return ctx?.checkout_step === "shipping_method";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si ya se eligió método de pago.
|
||||
*/
|
||||
function hasPaymentMethod(ctx) {
|
||||
return Boolean(ctx?.payment_method); // "cash" | "link"
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si ya se eligió método de envío.
|
||||
*/
|
||||
function hasShippingMethod(ctx) {
|
||||
return Boolean(ctx?.shipping_method); // "delivery" | "pickup"
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva el estado objetivo según el contexto actual y señales del turno.
|
||||
* `signals` es información determinística del motor del turno (no del LLM),
|
||||
@@ -67,20 +108,35 @@ export function deriveNextState(prevState, ctx = {}, signals = {}) {
|
||||
// Regla 2: si ya existe orden + link de pago, estamos esperando pago
|
||||
if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT;
|
||||
|
||||
// Regla 3: si intentó checkout pero falta dirección
|
||||
if ((signals.requested_checkout || signals.requested_address) && hasBasketItems(ctx) && !hasAddress(ctx)) {
|
||||
// Regla 3: si estamos clarificando método de pago
|
||||
if (isClarifyingPayment(ctx)) {
|
||||
return ConversationState.CLARIFYING_PAYMENT;
|
||||
}
|
||||
|
||||
// Regla 4: si estamos clarificando shipping
|
||||
if (isClarifyingShipping(ctx)) {
|
||||
return ConversationState.CLARIFYING_SHIPPING;
|
||||
}
|
||||
|
||||
// Regla 5: si intentó checkout, tiene shipping=delivery, pero falta dirección
|
||||
if (signals.requested_address || (hasShippingMethod(ctx) && ctx.shipping_method === "delivery" && !hasAddress(ctx))) {
|
||||
return ConversationState.AWAITING_ADDRESS;
|
||||
}
|
||||
|
||||
// Regla 4: si hay item pendiente sin completar cantidad
|
||||
// Regla 6: si hay items pendientes de clarificar (nuevo modelo acumulativo)
|
||||
if (hasPendingItems(ctx)) {
|
||||
return ConversationState.CLARIFYING_ITEMS;
|
||||
}
|
||||
|
||||
// Regla 7: si hay item pendiente sin completar cantidad (modelo legacy)
|
||||
if (hasPendingItem(ctx) && !signals.pending_item_completed) {
|
||||
return ConversationState.AWAITING_QUANTITY;
|
||||
}
|
||||
|
||||
// Regla 5: si hay carrito activo
|
||||
// Regla 8: si hay carrito activo
|
||||
if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE;
|
||||
|
||||
// Regla 6: si estamos mostrando opciones / esperando selección
|
||||
// Regla 9: si estamos mostrando opciones / esperando selección (modelo legacy)
|
||||
if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) {
|
||||
return ConversationState.BROWSING;
|
||||
}
|
||||
@@ -92,30 +148,55 @@ const ALLOWED = Object.freeze({
|
||||
[ConversationState.IDLE]: [
|
||||
ConversationState.IDLE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.CLARIFYING_ITEMS,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.BROWSING]: [
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.CLARIFYING_ITEMS,
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.CLARIFYING_ITEMS]: [
|
||||
ConversationState.CLARIFYING_ITEMS,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.IDLE,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_QUANTITY]: [
|
||||
ConversationState.AWAITING_QUANTITY,
|
||||
ConversationState.CLARIFYING_ITEMS,
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.BROWSING,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.CART_ACTIVE]: [
|
||||
ConversationState.CART_ACTIVE,
|
||||
ConversationState.CLARIFYING_ITEMS,
|
||||
ConversationState.CLARIFYING_PAYMENT,
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
ConversationState.BROWSING,
|
||||
],
|
||||
[ConversationState.CLARIFYING_PAYMENT]: [
|
||||
ConversationState.CLARIFYING_PAYMENT,
|
||||
ConversationState.CLARIFYING_SHIPPING,
|
||||
ConversationState.CART_ACTIVE, // Volver si cancela
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.CLARIFYING_SHIPPING]: [
|
||||
ConversationState.CLARIFYING_SHIPPING,
|
||||
ConversationState.AWAITING_ADDRESS, // Si elige delivery
|
||||
ConversationState.AWAITING_PAYMENT, // Si elige retiro (directo a crear orden)
|
||||
ConversationState.CLARIFYING_PAYMENT, // Volver a cambiar pago
|
||||
ConversationState.ERROR_RECOVERY,
|
||||
],
|
||||
[ConversationState.AWAITING_ADDRESS]: [
|
||||
ConversationState.AWAITING_ADDRESS,
|
||||
ConversationState.AWAITING_PAYMENT,
|
||||
|
||||
@@ -75,7 +75,7 @@ const NluV3JsonSchema = {
|
||||
properties: {
|
||||
intent: {
|
||||
type: "string",
|
||||
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "recommend", "other"],
|
||||
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_payment", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"],
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
language: { type: "string" },
|
||||
@@ -103,6 +103,10 @@ const NluV3JsonSchema = {
|
||||
},
|
||||
attributes: { type: "array", items: { type: "string" } },
|
||||
preparation: { type: "array", items: { type: "string" } },
|
||||
// Checkout: método de pago, envío, dirección
|
||||
payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] },
|
||||
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
|
||||
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
// Soporte para múltiples productos en un mensaje
|
||||
items: {
|
||||
anyOf: [
|
||||
@@ -231,6 +235,10 @@ function normalizeNluOutput(parsed, input) {
|
||||
selection: entities.selection ?? null,
|
||||
attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
|
||||
preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
|
||||
// Checkout entities (opcionales)
|
||||
payment_method: entities.payment_method ?? null,
|
||||
shipping_method: entities.shipping_method ?? null,
|
||||
address: entities.address ?? null,
|
||||
items: normalizedItems,
|
||||
};
|
||||
|
||||
@@ -250,27 +258,7 @@ function normalizeNluOutput(parsed, input) {
|
||||
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 = {
|
||||
@@ -293,6 +281,9 @@ function nluV3Fallback() {
|
||||
selection: null,
|
||||
attributes: [],
|
||||
preparation: [],
|
||||
payment_method: null,
|
||||
shipping_method: null,
|
||||
address: null,
|
||||
items: null,
|
||||
},
|
||||
needs: { catalog_lookup: false, knowledge_lookup: false },
|
||||
@@ -322,7 +313,15 @@ export async function llmNluV3({ input, model } = {}) {
|
||||
"- 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" +
|
||||
"- Si el usuario pide recomendación/sugerencias, usá intent='recommend' y needs.catalog_lookup=true.\n" +
|
||||
"- PREGUNTAS SOBRE DISPONIBILIDAD: Si el usuario pregunta si hay/venden/tienen un producto (ej: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'), usá intent='browse' con product_query=ese producto. needs.catalog_lookup=true.\n" +
|
||||
"- RECOMENDACIONES: SOLO usá intent='recommend' si el usuario pide sugerencias SIN mencionar ningún producto (ej: 'qué me recomendás?', 'qué me sugerís?'). Si menciona CUALQUIER producto, usá intent='add_to_cart' con product_query=ese producto. Ejemplos que son add_to_cart: 'me recomendás un vino?', 'recomendame un vino', 'qué vino me recomendás?', 'tenés algún vino bueno?' → TODOS son add_to_cart con product_query='vino'.\n" +
|
||||
"- COMPRAR/PEDIR PRODUCTOS: Si el usuario quiere comprar/pedir/llevar productos (ej: 'quiero comprar X', 'quiero X', 'dame X', 'necesito X', 'anotame X'), usá intent='add_to_cart'. needs.catalog_lookup=true. Aunque incluya un saludo o pida recomendación, si menciona productos específicos es add_to_cart.\n" +
|
||||
"- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" +
|
||||
"- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" +
|
||||
"- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" +
|
||||
"- SELECCIONAR PAGO: Si el usuario elige método de pago (ej: 'efectivo', 'tarjeta', 'link de pago', 'transferencia'), usá intent='select_payment'. Extraer entities.payment_method='cash'|'link'.\n" +
|
||||
"- SELECCIONAR ENVÍO: Si el usuario elige envío (ej: 'delivery', 'envío', 'que me lo traigan', 'retiro', 'paso a buscar'), usá intent='select_shipping'. Extraer entities.shipping_method='delivery'|'pickup'.\n" +
|
||||
"- DAR DIRECCIÓN: Si el usuario da una dirección de entrega, usá intent='provide_address'. Extraer entities.address con el texto de la dirección.\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" +
|
||||
@@ -351,53 +350,14 @@ export async function llmNluV3({ input, model } = {}) {
|
||||
|
||||
// intento 1
|
||||
const first = await jsonCompletion({ system: systemBase, user, model });
|
||||
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)) {
|
||||
const firstNormalized = normalizeNluOutput(first.parsed, input);
|
||||
const validationResult = validateNluV3(firstNormalized);
|
||||
if (validationResult) {
|
||||
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
|
||||
// retry 1 vez
|
||||
const systemRetry =
|
||||
systemBase +
|
||||
"\nTu respuesta anterior no validó el JSON Schema. Corregí el JSON para que cumpla estrictamente.\n" +
|
||||
@@ -406,50 +366,11 @@ export async function llmNluV3({ input, model } = {}) {
|
||||
try {
|
||||
const second = await jsonCompletion({ system: systemRetry, user, model });
|
||||
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)) {
|
||||
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 {
|
||||
return {
|
||||
nlu: nluV3Fallback(),
|
||||
raw_text: second.raw_text,
|
||||
model: second.model,
|
||||
@@ -517,5 +438,3 @@ export async function llmRecommendWriter({
|
||||
validation: { ok: false, errors: validateRecommendWriter.errors || [] },
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy llmPlan/llmExtract y NLU v2 removidos.
|
||||
|
||||
@@ -1,102 +1,33 @@
|
||||
import { getRecoRules } from "../2-identity/db/repo.js";
|
||||
import { retrieveCandidates } from "./catalogRetrieval.js";
|
||||
import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js";
|
||||
import { getSnapshotItemsByIds } from "../shared/wooSnapshot.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 || [],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Extrae los IDs de productos del carrito.
|
||||
*/
|
||||
function getBasketProductIds(basket_items) {
|
||||
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 || [],
|
||||
};
|
||||
return items
|
||||
.map(item => item.product_id || item.woo_product_id)
|
||||
.filter(id => id != null)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Obtiene los IDs de productos recomendados de las reglas que matchean.
|
||||
*/
|
||||
function collectRecommendedIds(rules, excludeIds = []) {
|
||||
const excludeSet = new Set(excludeIds);
|
||||
const ids = new Set();
|
||||
for (const rule of rules) {
|
||||
const recoIds = Array.isArray(rule.recommended_product_ids) ? rule.recommended_product_ids : [];
|
||||
for (const id of recoIds) {
|
||||
if (!excludeSet.has(id)) {
|
||||
ids.add(id);
|
||||
}
|
||||
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));
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export async function handleRecommend({
|
||||
@@ -106,14 +37,16 @@ export async function handleRecommend({
|
||||
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: [] };
|
||||
const context_patch = {};
|
||||
const audit = { basket_product_ids: [], rules_used: [], recommended_ids: [] };
|
||||
|
||||
if (!base_item?.name) {
|
||||
// 1. Obtener IDs de productos en el carrito
|
||||
const basketProductIds = getBasketProductIds(basket_items);
|
||||
audit.basket_product_ids = basketProductIds;
|
||||
|
||||
if (!basketProductIds.length) {
|
||||
return {
|
||||
reply: "¿Sobre qué producto querés recomendaciones?",
|
||||
reply: "Primero agregá algo al carrito y después te sugiero complementos perfectos.",
|
||||
actions: [],
|
||||
context_patch,
|
||||
audit,
|
||||
@@ -122,63 +55,15 @@ export async function handleRecommend({
|
||||
};
|
||||
}
|
||||
|
||||
// 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 }));
|
||||
// 2. Buscar reglas que matcheen con los productos del carrito
|
||||
const rules = await getRecoRulesByProductIds({ tenant_id: tenantId, product_ids: basketProductIds });
|
||||
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) {
|
||||
if (!rules.length) {
|
||||
// Fallback: no hay reglas configuradas para estos productos
|
||||
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", ");
|
||||
return {
|
||||
reply: `No encontré recomendaciones para ${base_item.name}. ¿Querés que te sugiera algo distinto?`,
|
||||
reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`,
|
||||
actions: [],
|
||||
context_patch,
|
||||
audit,
|
||||
@@ -187,22 +72,46 @@ export async function handleRecommend({
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
// 3. Obtener IDs de productos recomendados (excluyendo los que ya están en el carrito)
|
||||
const recommendedIds = collectRecommendedIds(rules, basketProductIds);
|
||||
audit.recommended_ids = recommendedIds;
|
||||
|
||||
if (!recommendedIds.length) {
|
||||
return {
|
||||
reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?",
|
||||
actions: [],
|
||||
context_patch,
|
||||
audit,
|
||||
asked_slot: null,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Obtener detalles de los productos recomendados
|
||||
const recommendedProducts = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) });
|
||||
|
||||
if (!recommendedProducts.length) {
|
||||
return {
|
||||
reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?",
|
||||
actions: [],
|
||||
context_patch,
|
||||
audit,
|
||||
asked_slot: null,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Construir respuesta con opciones
|
||||
const { question, pending } = buildPagedOptions({ candidates: recommendedProducts, pageSize: Math.min(9, limit) });
|
||||
|
||||
// Personalizar el mensaje según lo que tiene en el carrito
|
||||
const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 2).join(" y ");
|
||||
const intro = basketNames
|
||||
? `Para acompañar ${basketNames}, te recomiendo:`
|
||||
: "Te recomiendo estos productos:";
|
||||
|
||||
const reply = `${intro}\n\n${question}`;
|
||||
|
||||
context_patch.pending_clarification = pending;
|
||||
context_patch.pending_item = null;
|
||||
|
||||
@@ -212,6 +121,6 @@ export async function handleRecommend({
|
||||
context_patch,
|
||||
audit,
|
||||
asked_slot: null,
|
||||
candidates: merged.slice(0, limit),
|
||||
candidates: recommendedProducts.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,36 +146,7 @@ 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 = `
|
||||
const sql = `
|
||||
select *
|
||||
from sellable_items
|
||||
where tenant_id=$1
|
||||
@@ -184,26 +155,7 @@ 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" };
|
||||
return { items: rows.map(snapshotRowToItem), source: "snapshot" };
|
||||
}
|
||||
|
||||
export async function getSnapshotPriceByWooId({ tenantId, wooId }) {
|
||||
@@ -219,6 +171,27 @@ export async function getSnapshotPriceByWooId({ tenantId, wooId }) {
|
||||
return price == null ? null : Number(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene productos de sellable_items por sus woo_product_ids.
|
||||
* Usado para incluir productos encontrados vía aliases.
|
||||
*/
|
||||
export async function getSnapshotItemsByIds({ tenantId, wooProductIds }) {
|
||||
if (!Array.isArray(wooProductIds) || wooProductIds.length === 0) {
|
||||
return { items: [], source: "snapshot_by_id" };
|
||||
}
|
||||
const ids = wooProductIds.map(id => Number(id)).filter(id => id > 0);
|
||||
if (ids.length === 0) return { items: [], source: "snapshot_by_id" };
|
||||
|
||||
const placeholders = ids.map((_, i) => `$${i + 2}`).join(",");
|
||||
const sql = `
|
||||
select *
|
||||
from sellable_items
|
||||
where tenant_id=$1 and woo_id in (${placeholders})
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [tenantId, ...ids]);
|
||||
return { items: rows.map(snapshotRowToItem), source: "snapshot_by_id" };
|
||||
}
|
||||
|
||||
export async function upsertSnapshotItems({ tenantId, items, runId = null }) {
|
||||
const rows = Array.isArray(items) ? items : [];
|
||||
for (const item of rows) {
|
||||
|
||||
Reference in New Issue
Block a user