From 63b9ecef61963ad30c777ba4133ff220e5e1c953 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Sat, 17 Jan 2026 04:13:35 -0300 Subject: [PATCH] ux improved --- .cursor/debug.log | 153 ++++++ .../20260117120000_product_reco_rules.sql | 24 + ...60117121000_product_reco_rules_seed_ar.sql | 49 ++ env.example | 44 ++ public/app.js | 8 +- public/components/aliases-crud.js | 278 +++++++++++ public/components/chat-simulator.js | 84 ++-- public/components/conversation-inspector.js | 308 ++++++++++++ public/components/conversations-crud.js | 267 +++++++++++ public/components/ops-shell.js | 104 ++++- public/components/products-crud.js | 267 +++++++++++ public/components/recommendations-crud.js | 320 +++++++++++++ public/components/run-timeline.js | 141 +++++- public/components/users-crud.js | 243 ++++++++++ public/lib/api.js | 75 +++ scripts/import-woo-snapshot.mjs | 11 +- src/modules/0-ui/controllers/aliases.js | 68 +++ src/modules/0-ui/controllers/products.js | 42 +- .../0-ui/controllers/recommendations.js | 88 ++++ src/modules/0-ui/db/repo.js | 296 ++++++++++++ src/modules/0-ui/handlers/aliases.js | 19 + src/modules/0-ui/handlers/products.js | 16 + src/modules/0-ui/handlers/recommendations.js | 47 ++ src/modules/1-intake/handlers/evolution.js | 20 + src/modules/1-intake/routes/simulator.js | 21 +- src/modules/2-identity/db/repo.js | 38 +- src/modules/2-identity/services/pipeline.js | 71 ++- src/modules/3-turn-engine/catalogRetrieval.js | 20 + src/modules/3-turn-engine/openai.js | 334 ++++++++++++- src/modules/3-turn-engine/recommendations.js | 217 +++++++++ .../3-turn-engine/turnEngineV3.helpers.js | 16 + src/modules/3-turn-engine/turnEngineV3.js | 441 +++++++++++++++++- .../turnEngineV3.pendingSelection.js | 112 +++++ .../3-turn-engine/turnEngineV3.units.js | 51 ++ src/modules/shared/wooSnapshot.js | 48 ++ 35 files changed, 4266 insertions(+), 75 deletions(-) create mode 100644 .cursor/debug.log create mode 100644 db/migrations/20260117120000_product_reco_rules.sql create mode 100644 db/migrations/20260117121000_product_reco_rules_seed_ar.sql create mode 100644 env.example create mode 100644 public/components/aliases-crud.js create mode 100644 public/components/conversation-inspector.js create mode 100644 public/components/conversations-crud.js create mode 100644 public/components/products-crud.js create mode 100644 public/components/recommendations-crud.js create mode 100644 public/components/users-crud.js create mode 100644 src/modules/0-ui/controllers/aliases.js create mode 100644 src/modules/0-ui/controllers/recommendations.js create mode 100644 src/modules/0-ui/db/repo.js create mode 100644 src/modules/0-ui/handlers/aliases.js create mode 100644 src/modules/0-ui/handlers/recommendations.js create mode 100644 src/modules/3-turn-engine/recommendations.js create mode 100644 src/modules/3-turn-engine/turnEngineV3.helpers.js create mode 100644 src/modules/3-turn-engine/turnEngineV3.pendingSelection.js create mode 100644 src/modules/3-turn-engine/turnEngineV3.units.js diff --git a/.cursor/debug.log b/.cursor/debug.log new file mode 100644 index 0000000..4451987 --- /dev/null +++ b/.cursor/debug.log @@ -0,0 +1,153 @@ +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629587289} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":29},"timestamp":1768629587320} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"IDLE","isStale":false,"state_updated_at":"2026-01-17T05:59:47.320Z","has_context":true},"timestamp":1768629587326} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":0,"heights":16,"applied":0,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629587333} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":0,"heights":0,"applied":0,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629587336} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":0,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629587336} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":29,"state":"IDLE","memory_len":31,"pending_clarification":false,"pending_item":false,"last_shown_options":0},"timestamp":1768629587339} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":"kg","selection":"null","needs_catalog":true},"timestamp":1768629590578} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":false,"nlu_valid":true,"raw_len":303},"timestamp":1768629590580} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"asado","limit":12},"timestamp":1768629590590} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"asado","found":12,"sample_names":["ASADO VENTANA","Tapa de Asado Wagyu","Tapa De Asado (copia)"]},"timestamp":1768629590595} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"asado","aliases_count":4,"snapshot_count":12,"snapshot_source":"snapshot"},"timestamp":1768629590596} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"browse","next_state":"BROWSING","missing_fields":1,"actions_count":1},"timestamp":1768629590624} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":0,"applied":0,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629590646} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629590653} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":2,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629590653} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629595476} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":1},"timestamp":1768629595481} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"BROWSING","isStale":false,"state_updated_at":"2026-01-17T05:59:50.622Z","has_context":true},"timestamp":1768629595485} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629595487} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":2,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629595487} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629595493} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":1,"state":"BROWSING","memory_len":162,"pending_clarification":true,"pending_item":false,"last_shown_options":10},"timestamp":1768629595493} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H11","location":"openai.js:129","message":"selection_inferred","data":{"inferred":true,"pending_item":false,"has_shown_options":true,"text":"6"},"timestamp":1768629598519} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":null,"selection":"set","needs_catalog":false},"timestamp":1768629598519} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":false,"has_pending_clarification":true,"has_pending_item":false,"nlu_valid":true,"raw_len":359},"timestamp":1768629598519} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:239","message":"pending_clarification_resolved","data":{"kind":"chosen","selection_type":"index","selection_value":"6","text_len":1},"timestamp":1768629598520} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H14","location":"turnEngineV3.js:171","message":"pending_item_display_unit","data":{"name":"Asado Premium","categories":["Carnes > Vacuna"],"display_unit":"kg"},"timestamp":1768629598521} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"AWAITING_QUANTITY","missing_fields":1,"actions_count":0},"timestamp":1768629598521} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629598551} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629598553} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":4,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629598554} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629603948} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":6},"timestamp":1768629603951} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629603959} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":4,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629603960} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629603962} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"AWAITING_QUANTITY","isStale":false,"state_updated_at":"2026-01-17T05:59:58.532Z","has_context":true},"timestamp":1768629603963} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":6,"state":"AWAITING_QUANTITY","memory_len":200,"pending_clarification":false,"pending_item":true,"last_shown_options":0},"timestamp":1768629603968} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":"kg","selection":"null","needs_catalog":true},"timestamp":1768629607572} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:332","message":"pending_item_quantity","data":{"quantity_in":1,"unit_in":"kg","qty_resolved":1000,"text":"1 dije"},"timestamp":1768629607573} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":true,"nlu_valid":true,"raw_len":317},"timestamp":1768629607573} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"CART_ACTIVE","missing_fields":0,"actions_count":1},"timestamp":1768629607573} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629607595} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629607604} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":6,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629607604} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629612932} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":18},"timestamp":1768629612934} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629612940} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":6,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629612940} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629612946} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:00:07.593Z","has_context":true},"timestamp":1768629612947} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":18,"state":"CART_ACTIVE","memory_len":117,"pending_clarification":false,"pending_item":false,"last_shown_options":0},"timestamp":1768629612951} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"recommend","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629616398} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"recommend","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":false,"nlu_valid":true,"raw_len":305},"timestamp":1768629616398} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"recommend","next_state":"CART_ACTIVE","missing_fields":1,"actions_count":0},"timestamp":1768629616403} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629616430} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629616438} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":8,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629616438} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629618573} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":2},"timestamp":1768629618575} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:00:16.415Z","has_context":true},"timestamp":1768629618578} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629618585} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":2,"state":"CART_ACTIVE","memory_len":111,"pending_clarification":false,"pending_item":false,"last_shown_options":0},"timestamp":1768629618584} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629618588} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":8,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629618588} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"recommend","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629622528} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"recommend","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":false,"nlu_valid":true,"raw_len":305},"timestamp":1768629622528} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"provoleta","limit":9},"timestamp":1768629622538} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"provoleta","found":7,"sample_names":["Quesos Provoletas de Vaca Santa Rosa","Quesos Provoletas de Vaca Formagge","Queso Provoleta de Cabra"]},"timestamp":1768629622543} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"provoleta","aliases_count":0,"snapshot_count":7,"snapshot_source":"snapshot"},"timestamp":1768629622543} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"chimichurri","limit":9},"timestamp":1768629622563} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"chimichurri","found":6,"sample_names":["Finca la victoria (chimichurri, porotos, morrones, salsa criolla, berenjenas) - berenjena","Finca la victoria (chimichurri, porotos, morrones, salsa criolla, berenjenas) - morrones","Finca la victoria (chimichurri, porotos, morrones, salsa criolla, berenjenas) - porotos"]},"timestamp":1768629622566} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"chimichurri","aliases_count":0,"snapshot_count":6,"snapshot_source":"snapshot"},"timestamp":1768629622567} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"ensalada","limit":9},"timestamp":1768629622582} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"ensalada","found":0,"sample_names":[]},"timestamp":1768629622586} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"ensalada","aliases_count":0,"snapshot_count":0,"snapshot_source":"snapshot"},"timestamp":1768629622586} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"pan","limit":9},"timestamp":1768629622591} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"pan","found":9,"sample_names":["Panceta Bajo sodio Curada FETEADA","Panceta Bajo sodio Curada (copia)","PAN CASERO"]},"timestamp":1768629622595} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"pan","aliases_count":1,"snapshot_count":9,"snapshot_source":"snapshot"},"timestamp":1768629622595} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"vino tinto","limit":9},"timestamp":1768629622616} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"vino tinto","found":0,"sample_names":[]},"timestamp":1768629622619} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"vino tinto","aliases_count":0,"snapshot_count":0,"snapshot_source":"snapshot"},"timestamp":1768629622619} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"malbec","limit":9},"timestamp":1768629622624} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"malbec","found":9,"sample_names":["VINO Bizzotto Reserva Malbec","VINO Peñon de Agrelo Malbec","VINO CASTORE MALBEC"]},"timestamp":1768629622628} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"malbec","aliases_count":0,"snapshot_count":9,"snapshot_source":"snapshot"},"timestamp":1768629622629} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"recommend","next_state":"CART_ACTIVE","missing_fields":1,"actions_count":1},"timestamp":1768629637044} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629637120} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":10,"applied":10,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629637121} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":10,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":998,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629637122} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629663933} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":36},"timestamp":1768629663936} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":10,"applied":10,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629663943} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":10,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":998,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629663944} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:00:37.072Z","has_context":true},"timestamp":1768629663948} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":36,"state":"CART_ACTIVE","memory_len":213,"pending_clarification":true,"pending_item":false,"last_shown_options":10},"timestamp":1768629663954} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":10,"applied":10,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629663961} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H11","location":"openai.js:129","message":"selection_inferred","data":{"inferred":false,"pending_item":false,"has_shown_options":true,"text":"ok, agregame chimich"},"timestamp":1768629668306} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"add_to_cart","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629668306} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"add_to_cart","needsCatalog":true,"has_pending_clarification":true,"has_pending_item":false,"nlu_valid":true,"raw_len":445},"timestamp":1768629668306} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:239","message":"pending_clarification_resolved","data":{"kind":"ask","selection_type":null,"selection_value":null,"text_len":36},"timestamp":1768629668307} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"browse","next_state":"CART_ACTIVE","missing_fields":1,"actions_count":1},"timestamp":1768629668307} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":10,"applied":10,"scroll_height":1408,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629668385} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629668390} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629668390} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629676646} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":5},"timestamp":1768629676649} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:01:08.342Z","has_context":true},"timestamp":1768629676652} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629676658} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629676659} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":5,"state":"CART_ACTIVE","memory_len":305,"pending_clarification":true,"pending_item":false,"last_shown_options":10},"timestamp":1768629676661} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629676664} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H11","location":"openai.js:129","message":"selection_inferred","data":{"inferred":true,"pending_item":false,"has_shown_options":true,"text":"1 y 7"},"timestamp":1768629681604} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"add_to_cart","needsCatalog":true,"has_pending_clarification":true,"has_pending_item":false,"nlu_valid":true,"raw_len":370},"timestamp":1768629681605} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"add_to_cart","unit":null,"selection":"set","needs_catalog":true},"timestamp":1768629681604} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:239","message":"pending_clarification_resolved","data":{"kind":"chosen","selection_type":"index","selection_value":"1","text_len":5},"timestamp":1768629681605} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H14","location":"turnEngineV3.js:171","message":"pending_item_display_unit","data":{"name":"Chimichurri","categories":["Proveeduría > Sal pimienta y especias"],"display_unit":"unit"},"timestamp":1768629681605} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"ERROR_RECOVERY","missing_fields":1,"actions_count":0},"timestamp":1768629681606} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":12,"applied":12,"scroll_height":1639,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629681638} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":1618,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629681641} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629681642} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629697326} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":39},"timestamp":1768629697329} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"ERROR_RECOVERY","isStale":false,"state_updated_at":"2026-01-17T06:01:21.616Z","has_context":true},"timestamp":1768629697340} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":1618,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629697341} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":1618,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629697342} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629697342} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":39,"state":"ERROR_RECOVERY","memory_len":247,"pending_clarification":false,"pending_item":true,"last_shown_options":0},"timestamp":1768629697347} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629702666} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":true,"nlu_valid":true,"raw_len":513},"timestamp":1768629702666} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:332","message":"pending_item_quantity","data":{"quantity_in":0,"unit_in":null,"qty_resolved":null,"text":"chimichurri una unid"},"timestamp":1768629702666} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"AWAITING_QUANTITY","missing_fields":1,"actions_count":0},"timestamp":1768629702667} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":14,"applied":14,"scroll_height":1870,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629702691} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":16,"scroll_height":1850,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629702693} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1562,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629702693} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":0,"heights":14,"applied":0,"scroll_height":1926,"client_height":1926,"host_height":2074.300048828125,"box_height":2050.300048828125},"timestamp":1768630824934} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5491133230322@s.whatsapp.net","scroll_height":1926,"client_height":1926,"host_height":2073.5,"box_height":2049.5},"timestamp":1768630824934} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":2362,"client_height":2362,"host_height":2510,"box_height":2486},"timestamp":1768630824939} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":14,"applied":0,"scroll_height":1522,"client_height":1522,"host_height":1669.75,"box_height":1645.75},"timestamp":1768630848988} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1925,"client_height":1925,"host_height":2073,"box_height":2049},"timestamp":1768630849001} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5421133230322@s.whatsapp.net","scroll_height":1924,"client_height":1924,"host_height":2072.199951171875,"box_height":2048.199951171875},"timestamp":1768630849002} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":16,"applied":0,"scroll_height":1841,"client_height":1841,"host_height":1988.7000732421875,"box_height":1964.7000732421875},"timestamp":1768630855088} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5492233230322@s.whatsapp.net","scroll_height":1840,"client_height":1840,"host_height":1987.9000244140625,"box_height":1963.9000244140625},"timestamp":1768630855089} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":16,"scroll_height":2389,"client_height":2389,"host_height":2537,"box_height":2513},"timestamp":1768630855093} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":0,"scroll_height":2096,"client_height":2096,"host_height":2243.800048828125,"box_height":2219.800048828125},"timestamp":1768630857542} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":16,"scroll_height":2621,"client_height":2621,"host_height":2769,"box_height":2745},"timestamp":1768630857552} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":2620,"client_height":2620,"host_height":2768.199951171875,"box_height":2744.199951171875},"timestamp":1768630857552} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5491133230322@s.whatsapp.net","scroll_height":1366,"client_height":1136,"host_height":1284,"box_height":1260},"timestamp":1768631476665} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5421133230322@s.whatsapp.net","scroll_height":1555,"client_height":1156,"host_height":1304,"box_height":1280},"timestamp":1768631805322} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5421133230322@s.whatsapp.net","scroll_height":1555,"client_height":1156,"host_height":1304,"box_height":1280},"timestamp":1768632738993} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1868,"client_height":1196,"host_height":1344,"box_height":1320},"timestamp":1768633339517} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5492233230322@s.whatsapp.net","scroll_height":1674,"client_height":1046,"host_height":1194,"box_height":1170},"timestamp":1768633558760} +{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5492233230322@s.whatsapp.net","scroll_height":1674,"client_height":1046,"host_height":1194,"box_height":1170},"timestamp":1768633644474} diff --git a/db/migrations/20260117120000_product_reco_rules.sql b/db/migrations/20260117120000_product_reco_rules.sql new file mode 100644 index 0000000..28ad0dd --- /dev/null +++ b/db/migrations/20260117120000_product_reco_rules.sql @@ -0,0 +1,24 @@ +-- migrate:up +create table if not exists product_reco_rules ( + id bigserial primary key, + tenant_id uuid not null references tenants(id) on delete cascade, + rule_key text not null, + trigger jsonb not null default '{}'::jsonb, + queries jsonb not null default '[]'::jsonb, + boosts jsonb not null default '{}'::jsonb, + ask_slots jsonb not null default '[]'::jsonb, + active boolean not null default true, + priority integer not null default 100, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, rule_key) +); + +create index if not exists product_reco_rules_tenant_idx + on product_reco_rules (tenant_id); + +create index if not exists product_reco_rules_active_idx + on product_reco_rules (tenant_id, active, priority); + +-- migrate:down +drop table if exists product_reco_rules; diff --git a/db/migrations/20260117121000_product_reco_rules_seed_ar.sql b/db/migrations/20260117121000_product_reco_rules_seed_ar.sql new file mode 100644 index 0000000..353c89b --- /dev/null +++ b/db/migrations/20260117121000_product_reco_rules_seed_ar.sql @@ -0,0 +1,49 @@ +-- migrate:up +insert into product_reco_rules + (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority) +select + t.id as tenant_id, + 'asado_core' as rule_key, + jsonb_build_object( + 'keywords', jsonb_build_array('asado', 'parrilla', 'bife', 'entraña', 'vacio', 'vacío') + ) as trigger, + jsonb_build_array('provoleta', 'chimichurri', 'ensalada', 'pan') as queries, + '{}'::jsonb as boosts, + jsonb_build_array( + jsonb_build_object('slot','alcohol','question','¿Tomás alcohol?') + ) as ask_slots, + true as active, + 100 as priority +from tenants t +on conflict (tenant_id, rule_key) do nothing; + +insert into product_reco_rules + (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority) +select + t.id as tenant_id, + 'alcohol_yes' as rule_key, + jsonb_build_object('always', true, 'alcohol', true) as trigger, + jsonb_build_array('vino tinto', 'malbec', 'cabernet') as queries, + '{}'::jsonb as boosts, + '[]'::jsonb as ask_slots, + true as active, + 200 as priority +from tenants t +on conflict (tenant_id, rule_key) do nothing; + +insert into product_reco_rules + (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority) +select + t.id as tenant_id, + 'alcohol_no' as rule_key, + jsonb_build_object('always', true, 'alcohol', false) as trigger, + jsonb_build_array('agua con gas', 'gaseosa', 'limonada') as queries, + '{}'::jsonb as boosts, + '[]'::jsonb as ask_slots, + true as active, + 210 as priority +from tenants t +on conflict (tenant_id, rule_key) do nothing; + +-- migrate:down +delete from product_reco_rules where rule_key in ('asado_core','alcohol_yes','alcohol_no'); diff --git a/env.example b/env.example new file mode 100644 index 0000000..07c809a --- /dev/null +++ b/env.example @@ -0,0 +1,44 @@ +# Botino - Example Environment Variables +# Copy this file to .env and fill in the values + +# =================== +# Core +# =================== +PORT=3000 +TENANT_KEY=piaf +DATABASE_URL=postgresql://user:password@localhost:5432/botino +PG_POOL_MAX=10 +PG_IDLE_TIMEOUT_MS=30000 +PG_CONN_TIMEOUT_MS=5000 +APP_ENCRYPTION_KEY=your-32-char-encryption-key-here + +# =================== +# OpenAI +# =================== +OPENAI_API_KEY=sk-xxx +OPENAI_MODEL=gpt-4o-mini + +# =================== +# Turn Engine +# =================== +# v1 = pipeline actual (heurísticas + guardrails + LLM plan final) +# v2 = LLM-first NLU, deterministic core (nuevo motor) +TURN_ENGINE=v1 + +# =================== +# WooCommerce (fallback si falta config por tenant) +# =================== +WOO_BASE_URL=https://tu-tienda.com +WOO_CONSUMER_KEY=ck_xxx +WOO_CONSUMER_SECRET=cs_xxx + +# =================== +# Debug Flags (1/true/yes/on para activar) +# =================== +DEBUG_PERF=0 +DEBUG_WOO_HTTP=0 +DEBUG_WOO_PRODUCTS=0 +DEBUG_LLM=0 +DEBUG_EVOLUTION=0 +DEBUG_DB=0 +DEBUG_RESOLVE=0 diff --git a/public/app.js b/public/app.js index 8c203e2..6af9df8 100644 --- a/public/app.js +++ b/public/app.js @@ -1,8 +1,12 @@ import "./components/ops-shell.js"; -import "./components/conversation-list.js"; import "./components/run-timeline.js"; import "./components/chat-simulator.js"; -import "./components/debug-panel.js"; +import "./components/conversation-inspector.js"; +import "./components/conversations-crud.js"; +import "./components/users-crud.js"; +import "./components/products-crud.js"; +import "./components/aliases-crud.js"; +import "./components/recommendations-crud.js"; import { connectSSE } from "./lib/sse.js"; connectSSE(); diff --git a/public/components/aliases-crud.js b/public/components/aliases-crud.js new file mode 100644 index 0000000..370056e --- /dev/null +++ b/public/components/aliases-crud.js @@ -0,0 +1,278 @@ +import { api } from "../lib/api.js"; + +class AliasesCrud extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.items = []; + this.products = []; + this.selected = null; + this.loading = false; + this.searchQuery = ""; + this.editMode = null; // 'create' | 'edit' | null + + this.shadowRoot.innerHTML = ` + + +
+
+
Equivalencias (Aliases)
+
+ + +
+
+
Cargando...
+
+
+ +
+
Detalle
+
+
Seleccioná un alias o creá uno nuevo
+
+
+
+ `; + } + + connectedCallback() { + this.shadowRoot.getElementById("search").oninput = (e) => { + this.searchQuery = e.target.value; + clearTimeout(this._searchTimer); + this._searchTimer = setTimeout(() => this.load(), 300); + }; + + this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm(); + + this.load(); + this.loadProducts(); + } + + async load() { + this.loading = true; + this.renderList(); + + try { + const data = await api.aliases({ q: this.searchQuery, limit: 500 }); + this.items = data.items || []; + this.loading = false; + this.renderList(); + } catch (e) { + console.error("Error loading aliases:", e); + this.items = []; + this.loading = false; + this.renderList(); + } + } + + async loadProducts() { + try { + const data = await api.products({ limit: 500 }); + this.products = data.items || []; + } catch (e) { + console.error("Error loading products:", e); + this.products = []; + } + } + + renderList() { + const list = this.shadowRoot.getElementById("list"); + + if (this.loading) { + list.innerHTML = `
Cargando...
`; + return; + } + + if (!this.items.length) { + list.innerHTML = `
No se encontraron aliases
`; + return; + } + + list.innerHTML = ""; + for (const item of this.items) { + const el = document.createElement("div"); + el.className = "item" + (this.selected?.alias === item.alias ? " active" : ""); + + const product = this.products.find(p => p.woo_product_id === item.woo_product_id); + const productName = product?.name || `ID: ${item.woo_product_id || "—"}`; + const boost = item.boost ? `+${item.boost}` : ""; + + el.innerHTML = ` +
"${item.alias}"
+
→ ${productName} ${boost ? `(boost: ${boost})` : ""}
+ `; + + el.onclick = () => { + this.selected = item; + this.editMode = "edit"; + this.renderList(); + this.renderForm(); + }; + + list.appendChild(el); + } + } + + showCreateForm() { + this.selected = null; + this.editMode = "create"; + this.renderList(); + this.renderForm(); + } + + renderForm() { + const form = this.shadowRoot.getElementById("form"); + const title = this.shadowRoot.getElementById("formTitle"); + + if (!this.editMode) { + title.textContent = "Detalle"; + form.innerHTML = `
Seleccioná un alias o creá uno nuevo
`; + return; + } + + const isCreate = this.editMode === "create"; + title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias"; + + const alias = this.selected?.alias || ""; + const wooProductId = this.selected?.woo_product_id || ""; + const boost = this.selected?.boost || 0; + const categoryHint = this.selected?.category_hint || ""; + + const productOptions = this.products.map(p => + `` + ).join(""); + + form.innerHTML = ` +
+ + +
Sin tildes, en minusculas. Ej: "chimi" mapea a "Chimichurri"
+
+
+ + +
+
+ + +
Valor entre 0 y 10. Mayor boost = mayor prioridad en resultados
+
+
+ + +
+
+ + ${!isCreate ? `` : ""} + +
+ `; + + this.shadowRoot.getElementById("saveBtn").onclick = () => this.save(); + this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel(); + if (!isCreate) { + this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete(); + } + } + + async save() { + const aliasInput = this.shadowRoot.getElementById("aliasInput").value.trim().toLowerCase(); + const productInput = this.shadowRoot.getElementById("productInput").value; + const boostInput = parseFloat(this.shadowRoot.getElementById("boostInput").value) || 0; + const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim(); + + if (!aliasInput) { + alert("El alias es requerido"); + return; + } + if (!productInput) { + alert("Seleccioná un producto"); + return; + } + + const data = { + alias: aliasInput, + woo_product_id: parseInt(productInput, 10), + boost: boostInput, + category_hint: categoryInput || null, + }; + + try { + if (this.editMode === "create") { + await api.createAlias(data); + } else { + await api.updateAlias(this.selected.alias, data); + } + this.editMode = null; + this.selected = null; + await this.load(); + this.renderForm(); + } catch (e) { + console.error("Error saving alias:", e); + alert("Error guardando: " + (e.message || e)); + } + } + + async delete() { + if (!this.selected?.alias) return; + if (!confirm(`¿Eliminar el alias "${this.selected.alias}"?`)) return; + + try { + await api.deleteAlias(this.selected.alias); + this.editMode = null; + this.selected = null; + await this.load(); + this.renderForm(); + } catch (e) { + console.error("Error deleting alias:", e); + alert("Error eliminando: " + (e.message || e)); + } + } + + cancel() { + this.editMode = null; + this.selected = null; + this.renderList(); + this.renderForm(); + } +} + +customElements.define("aliases-crud", AliasesCrud); diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js index 6c14d33..e653bc6 100644 --- a/public/components/chat-simulator.js +++ b/public/components/chat-simulator.js @@ -9,39 +9,60 @@ class ChatSimulator extends HTMLElement { this._sending = false; this.shadowRoot.innerHTML = ` -
-
Evolution Sim (único chat)
-
- +
+
+
Evolution Sim
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
- +
+
Mensaje
+
+ +
+ + + +
+
-
- -
-
- -
-
Enviar mensaje
- -
- - -
-
`; } @@ -123,6 +144,17 @@ class ChatSimulator extends HTMLElement { last_run_id: null, }); emit("ui:selectedChat", { chat_id: from }); + + // Optimistic: mostrar burbuja del usuario inmediatamente + emit("message:optimistic", { + chat_id: from, + message_id: `optimistic-${Date.now()}`, + direction: "in", + text, + ts: new Date().toISOString(), + provider: "sim", + pushName: pushName || "test_lucas", + }); const payload = payloadOverride || buildPayload({ text, from, to, instance, pushName }); this._lastPayload = { ...payload, body: { ...payload.body, data: { ...payload.body.data, key: { ...payload.body.data.key, id: genId() } } } }; diff --git a/public/components/conversation-inspector.js b/public/components/conversation-inspector.js new file mode 100644 index 0000000..5d4de14 --- /dev/null +++ b/public/components/conversation-inspector.js @@ -0,0 +1,308 @@ +import { api } from "../lib/api.js"; +import { emit, on } from "../lib/bus.js"; + +class ConversationInspector extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.chatId = null; + this.messages = []; + this.runs = []; + this.rowOrder = []; + this.rowMap = new Map(); + this.heights = new Map(); + this._playing = false; + this._playIdx = 0; + this._timer = null; + + this.shadowRoot.innerHTML = ` + + +
+
Inspector
+
+
Seleccioná una conversación.
+
+ + + + +
+
+
+ `; + } + + connectedCallback() { + this.shadowRoot.getElementById("play").onclick = () => this.play(); + this.shadowRoot.getElementById("pause").onclick = () => this.pause(); + this.shadowRoot.getElementById("step").onclick = () => this.step(); + + this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => { + this.chatId = chat_id; + await this.loadData(); + }); + + this._unsubRun = on("run:created", (run) => { + if (this.chatId && run.chat_id === this.chatId) { + this.loadData(); + } + }); + + this._unsubLayout = on("ui:bubblesLayout", ({ chat_id, items }) => { + if (!this.chatId || chat_id !== this.chatId) return; + this.heights = new Map((items || []).map((it) => [it.message_id, it.height || 0])); + this.applyHeights(); + }); + + this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => { + if (!this.chatId || chat_id !== this.chatId) return; + const list = this.shadowRoot.getElementById("list"); + list.scrollTop = scrollTop || 0; + }); + + this._unsubSelectMessage = on("ui:selectedMessage", ({ message }) => { + const messageId = message?.message_id || null; + if (messageId) this.highlight(messageId); + }); + } + + disconnectedCallback() { + this._unsubSel?.(); + this._unsubRun?.(); + this._unsubLayout?.(); + this._unsubScroll?.(); + this._unsubSelectMessage?.(); + this.pause(); + } + + async loadData() { + const chatEl = this.shadowRoot.getElementById("chat"); + const metaEl = this.shadowRoot.getElementById("meta"); + const countEl = this.shadowRoot.getElementById("count"); + const list = this.shadowRoot.getElementById("list"); + + chatEl.textContent = this.chatId || "—"; + metaEl.textContent = "Cargando…"; + countEl.textContent = ""; + list.innerHTML = ""; + + if (!this.chatId) { + metaEl.textContent = "Seleccioná una conversación."; + return; + } + + try { + const [msgs, runs] = await Promise.all([ + api.messages({ chat_id: this.chatId, limit: 200 }), + api.runs({ chat_id: this.chatId, limit: 200 }), + ]); + this.messages = msgs.items || []; + this.runs = runs.items || []; + this.render(); + this.applyHeights(); + } catch (e) { + metaEl.textContent = `Error cargando: ${String(e?.message || e)}`; + this.messages = []; + this.runs = []; + } + } + + runMap() { + const map = new Map(); + for (const r of this.runs || []) { + map.set(r.run_id, r); + } + return map; + } + + buildRows() { + const runsById = this.runMap(); + const rows = []; + let nextRun = null; + for (let i = this.messages.length - 1; i >= 0; i--) { + const msg = this.messages[i]; + const run = msg?.run_id ? runsById.get(msg.run_id) : null; + if (run) nextRun = run; + rows[i] = { message: msg, run, nextRun }; + } + return rows; + } + + formatCart(items) { + const list = Array.isArray(items) ? items : []; + if (!list.length) return "—"; + return list + .map((it) => { + const label = it.label || it.name || `#${it.product_id}`; + const qty = it.quantity != null ? `${it.quantity}` : "?"; + const unit = it.unit || ""; + return `${label} (${qty}${unit ? " " + unit : ""})`; + }) + .join(" · "); + } + + toolSummary(tools = []) { + return tools.map((t) => ({ + type: t.type || t.name || "tool", + ok: t.ok !== false, + error: t.error || null, + })); + } + + render() { + const metaEl = this.shadowRoot.getElementById("meta"); + const countEl = this.shadowRoot.getElementById("count"); + const list = this.shadowRoot.getElementById("list"); + + metaEl.textContent = `Inspector de ${this.messages.length} mensajes.`; + countEl.textContent = this.messages.length ? `${this.messages.length} filas` : ""; + + list.innerHTML = ""; + this.rowMap.clear(); + this.rowOrder = []; + + const rows = this.buildRows(); + for (const row of rows) { + const msg = row.message; + const run = row.run; + const dir = msg?.direction === "in" ? "in" : "out"; + const el = document.createElement("div"); + el.className = `item ${dir}`; + el.dataset.messageId = msg.message_id; + + const intent = run?.llm_output?.intent || "—"; + const nextState = run?.llm_output?.next_state || "—"; + const prevState = row.nextRun?.prev_state || "—"; + const basket = run?.llm_output?.basket_resolved?.items || []; + const tools = this.toolSummary(run?.tools || []); + + const llmMeta = run?.llm_output?._llm || null; + const llmStatus = llmMeta?.audit?.validation?.ok === false ? "warn" : "ok"; + const llmNote = llmMeta?.audit?.validation?.ok === false + ? "NLU inválido (fallback)" + : llmMeta?.audit?.validation?.retried + ? "NLU ok (retry)" + : "NLU ok"; + + el.innerHTML = ` +
+
${dir === "in" ? "IN" : "OUT"}
+
${new Date(msg.ts).toLocaleString()}
+
STATE
+
${dir === "out" ? nextState : prevState}
+
INTENT
+
${dir === "out" ? intent : "—"}
+
NLU
+
${dir === "out" && llmMeta ? llmNote : "—"}
+
+
Carrito: ${dir === "out" ? this.formatCart(basket) : "—"}
+
+ ${tools + .map( + (t) => + `${t.type}` + ) + .join("")} +
+ `; + + el.onclick = () => { + this.highlight(msg.message_id); + }; + + list.appendChild(el); + this.rowMap.set(msg.message_id, el); + this.rowOrder.push(msg.message_id); + } + } + + applyHeights() { + const BUBBLE_MARGIN = 12; // same as .bubble margin-bottom in run-timeline + const MIN_ITEM_HEIGHT = 120; // minimum height for inspector items + + for (const [messageId, el] of this.rowMap.entries()) { + const bubbleHeight = this.heights.get(messageId) || 0; + // bubbleHeight includes offsetHeight + marginBottom + const bubbleContentHeight = Math.max(0, bubbleHeight - BUBBLE_MARGIN); + + // Use the max between bubble height and our minimum + const targetHeight = Math.max(bubbleContentHeight, MIN_ITEM_HEIGHT); + el.style.minHeight = `${targetHeight}px`; + el.style.marginBottom = `${BUBBLE_MARGIN}px`; + } + + // After applying, emit final heights back so bubbles can sync + requestAnimationFrame(() => this.emitInspectorHeights()); + } + + emitInspectorHeights() { + const items = []; + for (const [messageId, el] of this.rowMap.entries()) { + const styles = window.getComputedStyle(el); + const marginBottom = parseInt(styles.marginBottom || "0", 10) || 0; + items.push({ + message_id: messageId, + height: (el.offsetHeight || 0) + marginBottom, + }); + } + emit("ui:inspectorLayout", { chat_id: this.chatId, items }); + } + + highlight(messageId) { + for (const [id, el] of this.rowMap.entries()) { + el.classList.toggle("active", id === messageId); + } + emit("ui:highlightMessage", { message_id: messageId }); + const idx = this.rowOrder.indexOf(messageId); + if (idx >= 0) this._playIdx = idx; + } + + play() { + if (this._playing) return; + this._playing = true; + this._timer = setInterval(() => this.step(), 800); + } + + pause() { + this._playing = false; + if (this._timer) clearInterval(this._timer); + this._timer = null; + } + + step() { + if (!this.rowOrder.length) return; + if (this._playIdx >= this.rowOrder.length) { + this.pause(); + return; + } + const messageId = this.rowOrder[this._playIdx]; + this.highlight(messageId); + this._playIdx += 1; + } +} + +customElements.define("conversation-inspector", ConversationInspector); + diff --git a/public/components/conversations-crud.js b/public/components/conversations-crud.js new file mode 100644 index 0000000..a4499b2 --- /dev/null +++ b/public/components/conversations-crud.js @@ -0,0 +1,267 @@ +import { api } from "../lib/api.js"; +import { emit, on } from "../lib/bus.js"; + +class ConversationsCrud extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.items = []; + this.selected = null; + this.loading = false; + this.searchQuery = ""; + this.statusFilter = ""; + this.stateFilter = ""; + + this.shadowRoot.innerHTML = ` + + +
+
+
Conversaciones
+
+ + + +
+
+
Cargando...
+
+
+ +
+
Detalle
+
+
Selecciona una conversacion
+
+
+
+ `; + } + + connectedCallback() { + this.shadowRoot.getElementById("search").oninput = (e) => { + this.searchQuery = e.target.value; + clearTimeout(this._searchTimer); + this._searchTimer = setTimeout(() => this.load(), 300); + }; + + this.shadowRoot.getElementById("status").onchange = (e) => { + this.statusFilter = e.target.value; + this.load(); + }; + + this.shadowRoot.getElementById("state").onchange = (e) => { + this.stateFilter = e.target.value; + this.load(); + }; + + this._unsubUpsert = on("conversation:upsert", (conv) => { + const idx = this.items.findIndex(x => x.chat_id === conv.chat_id); + if (idx >= 0) this.items[idx] = conv; + else this.items.unshift(conv); + this.renderList(); + }); + + this.load(); + } + + disconnectedCallback() { + this._unsubUpsert?.(); + } + + async load() { + this.loading = true; + this.renderList(); + + try { + const data = await api.conversations({ + q: this.searchQuery, + status: this.statusFilter, + state: this.stateFilter + }); + this.items = data.items || []; + this.loading = false; + this.renderList(); + } catch (e) { + console.error("Error loading conversations:", e); + this.items = []; + this.loading = false; + this.renderList(); + } + } + + renderList() { + const list = this.shadowRoot.getElementById("list"); + + if (this.loading) { + list.innerHTML = `
Cargando...
`; + return; + } + + if (!this.items.length) { + list.innerHTML = `
No se encontraron conversaciones
`; + return; + } + + list.innerHTML = ""; + for (const item of this.items) { + const el = document.createElement("div"); + el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : ""); + + const dotClass = item.status === "ok" ? "ok" : (item.status === "warn" ? "warn" : "err"); + const time = item.last_activity ? new Date(item.last_activity).toLocaleString() : "—"; + + el.innerHTML = ` +
+ + ${item.from || item.chat_id} +
+
${item.chat_id}
+
+ state: ${item.state || "—"} + intent: ${item.intent || "—"} + ${time} +
+ `; + + el.onclick = () => { + this.selected = item; + this.renderList(); + this.renderDetail(); + }; + + list.appendChild(el); + } + } + + renderDetail() { + const detail = this.shadowRoot.getElementById("detail"); + const title = this.shadowRoot.getElementById("detailTitle"); + + if (!this.selected) { + title.textContent = "Detalle"; + detail.innerHTML = `
Selecciona una conversacion
`; + return; + } + + const c = this.selected; + title.textContent = c.from || c.chat_id; + + detail.innerHTML = ` +
+ +
${c.chat_id}
+
+
+ +
${c.from || "—"}
+
+
+ +
${c.state || "—"}
+
+
+ +
${c.intent || "—"}
+
+
+ +
${c.status || "—"}
+
+
+ +
${c.last_activity ? new Date(c.last_activity).toLocaleString() : "—"}
+
+
+ +
${c.last_run_id || "—"}
+
+
+ + + +
+ `; + + detail.scrollTop = 0; + + this.shadowRoot.getElementById("openChat").onclick = () => { + emit("ui:selectedChat", { chat_id: c.chat_id }); + emit("ui:switchView", { view: "chat" }); + }; + + this.shadowRoot.getElementById("retryLast").onclick = async () => { + try { + await api.retryLast(c.chat_id); + alert("Retry ejecutado"); + } catch (e) { + alert("Error: " + (e.message || e)); + } + }; + + this.shadowRoot.getElementById("deleteConv").onclick = async () => { + if (!confirm(`¿Eliminar la conversacion de "${c.chat_id}"?`)) return; + try { + await api.deleteConversation(c.chat_id); + this.selected = null; + await this.load(); + this.renderDetail(); + } catch (e) { + alert("Error: " + (e.message || e)); + } + }; + } +} + +customElements.define("conversations-crud", ConversationsCrud); diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js index 9c59171..ede2cd5 100644 --- a/public/components/ops-shell.js +++ b/public/components/ops-shell.js @@ -4,38 +4,87 @@ class OpsShell extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); + this._currentView = "chat"; this.shadowRoot.innerHTML = `

Bot Ops Console

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