From 204403560e5a5961031725b3ef99d8a035ec3861 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Sat, 17 Jan 2026 06:31:49 -0300 Subject: [PATCH] mejoras en el modelo de clarificacion de productos --- .cursor/debug.log | 153 --- .../20260118100000_reco_rules_product_ids.sql | 15 + public/components/aliases-crud.js | 2 +- public/components/chat-simulator.js | 6 +- public/components/conversation-inspector.js | 79 +- public/components/products-crud.js | 246 +++- public/components/recommendations-crud.js | 265 ++++- public/components/run-timeline.js | 72 +- public/components/users-crud.js | 38 +- public/lib/api.js | 24 + src/modules/0-ui/controllers/products.js | 59 +- src/modules/0-ui/db/repo.js | 96 +- src/modules/0-ui/handlers/products.js | 17 +- src/modules/0-ui/handlers/recommendations.js | 8 +- src/modules/1-intake/handlers/evolution.js | 22 +- src/modules/1-intake/routes/simulator.js | 7 +- src/modules/2-identity/db/repo.js | 22 +- src/modules/2-identity/services/pipeline.js | 80 +- src/modules/3-turn-engine/catalogRetrieval.js | 53 +- src/modules/3-turn-engine/fsm.js | 91 +- src/modules/3-turn-engine/openai.js | 137 +-- src/modules/3-turn-engine/recommendations.js | 241 ++-- src/modules/3-turn-engine/turnEngineV3.js | 1007 ++++++++++++++--- src/modules/shared/wooSnapshot.js | 73 +- 24 files changed, 1940 insertions(+), 873 deletions(-) delete mode 100644 .cursor/debug.log create mode 100644 db/migrations/20260118100000_reco_rules_product_ids.sql diff --git a/.cursor/debug.log b/.cursor/debug.log deleted file mode 100644 index 4451987..0000000 --- a/.cursor/debug.log +++ /dev/null @@ -1,153 +0,0 @@ -{"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/20260118100000_reco_rules_product_ids.sql b/db/migrations/20260118100000_reco_rules_product_ids.sql new file mode 100644 index 0000000..b93a414 --- /dev/null +++ b/db/migrations/20260118100000_reco_rules_product_ids.sql @@ -0,0 +1,15 @@ +-- migrate:up +-- Agregar columnas para asociación directa producto-producto +alter table product_reco_rules + add column if not exists trigger_product_ids integer[] not null default '{}', + add column if not exists recommended_product_ids integer[] not null default '{}'; + +-- Índice GIN para búsqueda rápida por trigger_product_ids +create index if not exists product_reco_rules_trigger_ids_idx + on product_reco_rules using gin (trigger_product_ids); + +-- migrate:down +drop index if exists product_reco_rules_trigger_ids_idx; +alter table product_reco_rules + drop column if exists trigger_product_ids, + drop column if exists recommended_product_ids; diff --git a/public/components/aliases-crud.js b/public/components/aliases-crud.js index 370056e..0e753de 100644 --- a/public/components/aliases-crud.js +++ b/public/components/aliases-crud.js @@ -102,7 +102,7 @@ class AliasesCrud extends HTMLElement { async loadProducts() { try { - const data = await api.products({ limit: 500 }); + const data = await api.products({ limit: 2000 }); this.products = data.items || []; } catch (e) { console.error("Error loading products:", e); diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js index e653bc6..03e6163 100644 --- a/public/components/chat-simulator.js +++ b/public/components/chat-simulator.js @@ -133,7 +133,7 @@ class ChatSimulator extends HTMLElement { return; } - // Optimistic: que aparezca en la columna izquierda al instante + // 1. Actualizar lista de conversaciones emit("conversation:upsert", { chat_id: from, from: pushName || "test_lucas", @@ -143,9 +143,11 @@ class ChatSimulator extends HTMLElement { last_activity: new Date().toISOString(), last_run_id: null, }); + + // 2. Seleccionar el chat (si es el mismo, no recarga - optimizado en run-timeline) emit("ui:selectedChat", { chat_id: from }); - // Optimistic: mostrar burbuja del usuario inmediatamente + // 3. Mostrar burbuja optimista INMEDIATAMENTE emit("message:optimistic", { chat_id: from, message_id: `optimistic-${Date.now()}`, diff --git a/public/components/conversation-inspector.js b/public/components/conversation-inspector.js index 5d4de14..d233830 100644 --- a/public/components/conversation-inspector.js +++ b/public/components/conversation-inspector.js @@ -61,6 +61,8 @@ class ConversationInspector extends HTMLElement { this.shadowRoot.getElementById("step").onclick = () => this.step(); this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => { + // Si es el mismo chat, no recargar (para no borrar items optimistas) + if (this.chatId === chat_id) return; this.chatId = chat_id; await this.loadData(); }); @@ -87,6 +89,17 @@ class ConversationInspector extends HTMLElement { const messageId = message?.message_id || null; if (messageId) this.highlight(messageId); }); + + // Listen for optimistic messages to add placeholder item + this._unsubOptimistic = on("message:optimistic", (msg) => { + if (!this.chatId) { + this.chatId = msg.chat_id; + this.shadowRoot.getElementById("chat").textContent = msg.chat_id; + this.shadowRoot.getElementById("meta").textContent = "Nueva conversación"; + } + if (msg.chat_id !== this.chatId) return; + this.addOptimisticItem(msg); + }); } disconnectedCallback() { @@ -95,6 +108,7 @@ class ConversationInspector extends HTMLElement { this._unsubLayout?.(); this._unsubScroll?.(); this._unsubSelectMessage?.(); + this._unsubOptimistic?.(); this.pause(); } @@ -180,6 +194,14 @@ class ConversationInspector extends HTMLElement { metaEl.textContent = `Inspector de ${this.messages.length} mensajes.`; countEl.textContent = this.messages.length ? `${this.messages.length} filas` : ""; + // Preserve optimistic items before clearing + const optimisticItems = [...list.querySelectorAll('.item[data-message-id^="optimistic-"]')]; + + // Obtener timestamps de mensajes IN del servidor para comparar + const serverInTimestamps = this.messages + .filter(m => m.direction === "in") + .map(m => new Date(m.ts).getTime()); + list.innerHTML = ""; this.rowMap.clear(); this.rowOrder = []; @@ -196,7 +218,7 @@ class ConversationInspector extends HTMLElement { 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 basket = run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || []; const tools = this.toolSummary(run?.tools || []); const llmMeta = run?.llm_output?._llm || null; @@ -237,6 +259,23 @@ class ConversationInspector extends HTMLElement { this.rowMap.set(msg.message_id, el); this.rowOrder.push(msg.message_id); } + + // Re-add preserved optimistic items ONLY if no server message covers it + for (const optItem of optimisticItems) { + // Obtener timestamp del optimista (está en el ID: optimistic-{timestamp}) + const msgId = optItem.dataset.messageId; + const optTs = parseInt(msgId.replace("optimistic-", ""), 10) || 0; + + // Si hay un mensaje del servidor con timestamp cercano (10 seg), no re-agregar + const hasServerMatch = serverInTimestamps.some(ts => Math.abs(ts - optTs) < 10000); + if (hasServerMatch) { + continue; + } + + list.appendChild(optItem); + this.rowMap.set(msgId, optItem); + this.rowOrder.push(msgId); + } } applyHeights() { @@ -302,6 +341,44 @@ class ConversationInspector extends HTMLElement { this.highlight(messageId); this._playIdx += 1; } + + addOptimisticItem(msg) { + const list = this.shadowRoot.getElementById("list"); + if (!list) return; + + // Remove any existing optimistic item + const existing = list.querySelector(`.item[data-message-id^="optimistic-"]`); + if (existing) existing.remove(); + + const el = document.createElement("div"); + el.className = "item in"; + el.dataset.messageId = msg.message_id; + + el.innerHTML = ` +
+
IN
+
${new Date(msg.ts).toLocaleString()}
+
STATE
+
+
INTENT
+
+
NLU
+
procesando...
+
+
Carrito:
+
+ `; + + list.appendChild(el); + list.scrollTop = list.scrollHeight; + + this.rowMap.set(msg.message_id, el); + this.rowOrder.push(msg.message_id); + + // Apply min height + el.style.minHeight = "120px"; + el.style.marginBottom = "12px"; + } } customElements.define("conversation-inspector", ConversationInspector); diff --git a/public/components/products-crud.js b/public/components/products-crud.js index 628c1c1..81c364b 100644 --- a/public/components/products-crud.js +++ b/public/components/products-crud.js @@ -5,7 +5,8 @@ class ProductsCrud extends HTMLElement { super(); this.attachShadow({ mode: "open" }); this.items = []; - this.selected = null; + this.selectedItems = []; // Array de productos seleccionados + this.lastClickedIndex = -1; // Para Shift+Click this.loading = false; this.searchQuery = ""; this.stockFilter = false; @@ -29,9 +30,10 @@ class ProductsCrud extends HTMLElement { button.secondary:hover { background:#2d3e52; } .list { flex:1; overflow-y:auto; } - .item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; } + .item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; } .item:hover { border-color:#1f6feb; } .item.active { border-color:#1f6feb; background:#111b2a; } + .item.selected { border-color:#2ecc71; background:#0f2a1a; } .item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; } .item-meta { font-size:12px; color:#8aa0b5; } .item-price { color:#2ecc71; font-weight:600; } @@ -182,9 +184,15 @@ class ProductsCrud extends HTMLElement { } list.innerHTML = ""; - for (const item of filteredItems) { + this._filteredItems = filteredItems; // Guardar referencia para Shift+Click + + for (let i = 0; i < filteredItems.length; i++) { + const item = filteredItems[i]; const el = document.createElement("div"); - el.className = "item" + (this.selected?.woo_product_id === item.woo_product_id ? " active" : ""); + const isSelected = this.selectedItems.some(s => s.woo_product_id === item.woo_product_id); + const isSingleSelected = isSelected && this.selectedItems.length === 1; + el.className = "item" + (isSelected ? " selected" : "") + (isSingleSelected ? " active" : ""); + el.dataset.index = i; const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—"; const sku = item.sku || "—"; @@ -192,9 +200,13 @@ class ProductsCrud extends HTMLElement { const stockBadge = stock === "instock" ? `En stock` : `Sin stock`; + + // Mostrar unidad actual si está definida + const unit = item.sell_unit || item.payload?._sell_unit_override; + const unitBadge = unit ? `${unit === 'unit' ? 'Unidad' : 'Kg'}` : ''; el.innerHTML = ` -
${item.name || "Sin nombre"} ${stockBadge}
+
${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}
${price} · SKU: ${sku} · @@ -202,30 +214,78 @@ class ProductsCrud extends HTMLElement {
`; - 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; - }; + el.onclick = (e) => this.handleItemClick(e, item, i); list.appendChild(el); } } + handleItemClick(e, item, index) { + if (e.shiftKey && this.lastClickedIndex >= 0) { + // Shift+Click: seleccionar rango + const start = Math.min(this.lastClickedIndex, index); + const end = Math.max(this.lastClickedIndex, index); + + for (let i = start; i <= end; i++) { + const rangeItem = this._filteredItems[i]; + if (!this.selectedItems.some(s => s.woo_product_id === rangeItem.woo_product_id)) { + this.selectedItems.push(rangeItem); + } + } + } else if (e.ctrlKey || e.metaKey) { + // Ctrl+Click: toggle individual + const idx = this.selectedItems.findIndex(s => s.woo_product_id === item.woo_product_id); + if (idx >= 0) { + this.selectedItems.splice(idx, 1); + } else { + this.selectedItems.push(item); + } + } else { + // Click normal: selección única + this.selectedItems = [item]; + } + + this.lastClickedIndex = index; + this.renderList(); + this.renderDetail(); + + // Scroll detail panel to top + const detail = this.shadowRoot.getElementById("detail"); + if (detail) detail.scrollTop = 0; + } + renderDetail() { const detail = this.shadowRoot.getElementById("detail"); - if (!this.selected) { + if (!this.selectedItems.length) { 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("; ") || "—"; + // Si hay múltiples seleccionados, mostrar vista de edición masiva + if (this.selectedItems.length > 1) { + this.renderMultiDetail(); + return; + } + + const p = this.selectedItems[0]; + + // Categorías: pueden estar en p.categories (string JSON) o p.payload.categories (array) + let categoriesArray = []; + if (p.categories) { + try { + const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories; + categoriesArray = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)]; + } catch { categoriesArray = [String(p.categories)]; } + } else if (p.payload?.categories) { + categoriesArray = p.payload.categories.map(c => c.name || c); + } + const categoriesText = categoriesArray.join(", "); + + const attributes = (p.attributes_normalized || p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—"; + + // Determinar unidad actual (de payload o inferida) + const currentUnit = p.sell_unit || p.payload?._sell_unit_override || this.inferUnit(p); detail.innerHTML = `
@@ -245,13 +305,31 @@ class ProductsCrud extends HTMLElement {
${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}
- -
${categories}
+ +
+ +
+
+ Define si este producto se vende por peso o por unidad +
+
+
+ + +
+ Categorías del producto, separadas por coma +
${attributes}
+
+ +
${p.refreshed_at ? new Date(p.refreshed_at).toLocaleString() : "—"}
@@ -261,6 +339,136 @@ class ProductsCrud extends HTMLElement {
${JSON.stringify(p.payload || {}, null, 2)}
`; + + // Bind save button + this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct(); + } + + async saveProduct() { + if (this.selectedItems.length !== 1) return; + + const p = this.selectedItems[0]; + const btn = this.shadowRoot.getElementById("saveProduct"); + const sellUnitSelect = this.shadowRoot.getElementById("sellUnit"); + const categoriesInput = this.shadowRoot.getElementById("categoriesInput"); + + const sell_unit = sellUnitSelect.value; + const categories = categoriesInput.value.split(",").map(s => s.trim()).filter(Boolean); + + btn.disabled = true; + btn.textContent = "Guardando..."; + + try { + await api.updateProduct(p.woo_product_id, { sell_unit, categories }); + + // Actualizar localmente + p.sell_unit = sell_unit; + p.categories = JSON.stringify(categories.map(name => ({ name }))); + const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id); + if (idx >= 0) { + this.items[idx].sell_unit = sell_unit; + this.items[idx].categories = p.categories; + } + + btn.textContent = "Guardado!"; + this.renderList(); + setTimeout(() => { btn.textContent = "Guardar cambios"; btn.disabled = false; }, 1500); + } catch (e) { + console.error("Error saving product:", e); + alert("Error guardando: " + (e.message || e)); + btn.textContent = "Guardar cambios"; + btn.disabled = false; + } + } + + renderMultiDetail() { + const detail = this.shadowRoot.getElementById("detail"); + const count = this.selectedItems.length; + const names = this.selectedItems.slice(0, 5).map(p => p.name).join(", "); + const moreText = count > 5 ? ` y ${count - 5} más...` : ""; + + detail.innerHTML = ` +
+ +
${count} productos
+
${names}${moreText}
+
+
+ +
+ + +
+
+ Se aplicará a todos los productos seleccionados +
+
+
+ +
+ `; + + this.shadowRoot.getElementById("saveUnit").onclick = () => this.saveProductUnit(); + this.shadowRoot.getElementById("clearSelection").onclick = () => { + this.selectedItems = []; + this.lastClickedIndex = -1; + this.renderList(); + this.renderDetail(); + }; + } + + inferUnit(p) { + const name = String(p.name || "").toLowerCase(); + const cats = (p.payload?.categories || []).map(c => String(c.name || c.slug || "").toLowerCase()); + const allText = name + " " + cats.join(" "); + + // Productos que típicamente se venden por unidad + if (/chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especias?|vino|vinos|bebida|cerveza|gaseosa|whisky|ron|gin|vodka|fernet/i.test(allText)) { + return "unit"; + } + return "kg"; + } + + async saveProductUnit() { + if (!this.selectedItems.length) return; + + const select = this.shadowRoot.getElementById("sellUnit"); + const btn = this.shadowRoot.getElementById("saveUnit"); + const unit = select.value; + const count = this.selectedItems.length; + + btn.disabled = true; + btn.textContent = "Guardando..."; + + try { + // IDs de todos los productos seleccionados + const wooProductIds = this.selectedItems.map(p => p.woo_product_id); + + // Un solo request para todos + await api.updateProductsUnit(wooProductIds, { sell_unit: unit }); + + // Actualizar localmente + for (const p of this.selectedItems) { + p.sell_unit = unit; + const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id); + if (idx >= 0) this.items[idx].sell_unit = unit; + } + + btn.textContent = `Guardado ${count}!`; + this.renderList(); // Actualizar badges en lista + setTimeout(() => { + btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar"; + btn.disabled = false; + }, 1500); + } catch (e) { + console.error("Error saving product unit:", e); + alert("Error guardando: " + (e.message || e)); + btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar"; + btn.disabled = false; + } } } diff --git a/public/components/recommendations-crud.js b/public/components/recommendations-crud.js index 917966a..aea69e9 100644 --- a/public/components/recommendations-crud.js +++ b/public/components/recommendations-crud.js @@ -9,12 +9,20 @@ class RecommendationsCrud extends HTMLElement { this.loading = false; this.searchQuery = ""; this.editMode = null; // 'create' | 'edit' | null + + // Cache de productos para el selector + this.allProducts = []; + this.productsLoaded = false; + + // Productos seleccionados en el formulario + this.selectedTriggerProducts = []; + this.selectedRecommendedProducts = []; this.shadowRoot.innerHTML = `
@@ -72,7 +112,7 @@ class RecommendationsCrud extends HTMLElement {
Detalle
-
Seleccioná una regla o creá una nueva
+
Selecciona una regla o crea una nueva
@@ -89,6 +129,19 @@ class RecommendationsCrud extends HTMLElement { this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm(); this.load(); + this.loadProducts(); + } + + async loadProducts() { + if (this.productsLoaded) return; + try { + const data = await api.products({ limit: 2000 }); + this.allProducts = (data.items || []).filter(p => p.stock_status === "instock"); + this.productsLoaded = true; + } catch (e) { + console.error("Error loading products:", e); + this.allProducts = []; + } } async load() { @@ -108,6 +161,11 @@ class RecommendationsCrud extends HTMLElement { } } + getProductName(id) { + const p = this.allProducts.find(x => x.woo_product_id === id); + return p?.name || `Producto #${id}`; + } + renderList() { const list = this.shadowRoot.getElementById("list"); @@ -126,10 +184,15 @@ class RecommendationsCrud extends HTMLElement { 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; + // Mostrar productos trigger + const triggerIds = item.trigger_product_ids || []; + const triggerNames = triggerIds.slice(0, 3).map(id => this.getProductName(id)).join(", "); + const triggerMore = triggerIds.length > 3 ? ` (+${triggerIds.length - 3})` : ""; + + // Mostrar productos recomendados + const recoIds = item.recommended_product_ids || []; + const recoNames = recoIds.slice(0, 3).map(id => this.getProductName(id)).join(", "); + const recoMore = recoIds.length > 3 ? ` (+${recoIds.length - 3})` : ""; el.innerHTML = `
@@ -137,13 +200,15 @@ class RecommendationsCrud extends HTMLElement { ${item.active ? "Activa" : "Inactiva"} P: ${item.priority}
-
Keywords: ${keywords}
-
→ ${queries}${hasMore ? "..." : ""}
+
Cuando piden: ${triggerNames || "—"}${triggerMore}
+
→ Recomendar: ${recoNames || "—"}${recoMore}
`; el.onclick = () => { this.selected = item; this.editMode = "edit"; + this.selectedTriggerProducts = [...(item.trigger_product_ids || [])]; + this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])]; this.renderList(); this.renderForm(); }; @@ -155,6 +220,8 @@ class RecommendationsCrud extends HTMLElement { showCreateForm() { this.selected = null; this.editMode = "create"; + this.selectedTriggerProducts = []; + this.selectedRecommendedProducts = []; this.renderList(); this.renderForm(); } @@ -165,7 +232,7 @@ class RecommendationsCrud extends HTMLElement { if (!this.editMode) { title.textContent = "Detalle"; - form.innerHTML = `
Seleccioná una regla o creá una nueva
`; + form.innerHTML = `
Selecciona una regla o crea una nueva
`; return; } @@ -173,31 +240,20 @@ class RecommendationsCrud extends HTMLElement { 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
+ + +
Identificador unico, sin espacios
-
Mayor = primero
@@ -209,21 +265,23 @@ class RecommendationsCrud extends HTMLElement {
- - -
Palabras que activan esta regla, separadas por coma
+ +
+ +
+
+
+
Productos que activan esta recomendacion
- - -
Productos a buscar cuando se activa la regla, separados por coma
-
- -
- - -
El bot preguntara al usuario sobre estos temas de forma natural
+ +
+ +
+
+
+
Productos a sugerir al cliente
@@ -233,48 +291,145 @@ class RecommendationsCrud extends HTMLElement {
`; + // Setup event handlers this.shadowRoot.getElementById("saveBtn").onclick = () => this.save(); this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel(); if (!isCreate) { this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete(); } + + // Setup product selectors + this.setupProductSelector("trigger", this.selectedTriggerProducts); + this.setupProductSelector("reco", this.selectedRecommendedProducts); } - parseCommaSeparated(str) { - return String(str || "") - .split(",") - .map(s => s.trim().toLowerCase()) - .filter(Boolean); + setupProductSelector(type, selectedIds) { + const searchInput = this.shadowRoot.getElementById(`${type}Search`); + const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`); + const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`); + + const renderSelected = () => { + const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts; + if (!ids.length) { + selectedContainer.innerHTML = `Ningun producto seleccionado`; + return; + } + selectedContainer.innerHTML = ids.map(id => { + const name = this.getProductName(id); + return `${name}×`; + }).join(""); + + selectedContainer.querySelectorAll(".remove").forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const id = parseInt(btn.parentElement.dataset.id, 10); + if (type === "trigger") { + this.selectedTriggerProducts = this.selectedTriggerProducts.filter(x => x !== id); + } else { + this.selectedRecommendedProducts = this.selectedRecommendedProducts.filter(x => x !== id); + } + renderSelected(); + renderDropdown(searchInput.value); + }; + }); + }; + + const renderDropdown = (query) => { + const q = (query || "").toLowerCase().trim(); + const selectedSet = new Set(type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts); + + let filtered = this.allProducts; + if (q) { + filtered = filtered.filter(p => p.name.toLowerCase().includes(q)); + } + filtered = filtered.slice(0, 50); // Limit for performance + + if (!q && !filtered.length) { + dropdown.classList.remove("open"); + return; + } + + dropdown.innerHTML = filtered.map(p => { + const isSelected = selectedSet.has(p.woo_product_id); + return ` +
+ ${p.name} + $${p.price || 0} +
+ `; + }).join(""); + + dropdown.querySelectorAll(".product-option").forEach(opt => { + opt.onclick = () => { + const id = parseInt(opt.dataset.id, 10); + if (type === "trigger") { + if (!this.selectedTriggerProducts.includes(id)) { + this.selectedTriggerProducts.push(id); + } + } else { + if (!this.selectedRecommendedProducts.includes(id)) { + this.selectedRecommendedProducts.push(id); + } + } + searchInput.value = ""; + dropdown.classList.remove("open"); + renderSelected(); + }; + }); + + dropdown.classList.add("open"); + }; + + searchInput.oninput = () => { + clearTimeout(this[`_${type}Timer`]); + this[`_${type}Timer`] = setTimeout(() => renderDropdown(searchInput.value), 150); + }; + + searchInput.onfocus = () => { + if (searchInput.value || this.allProducts.length) { + renderDropdown(searchInput.value); + } + }; + + // Close dropdown on outside click + document.addEventListener("click", (e) => { + if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) { + dropdown.classList.remove("open"); + } + }); + + renderSelected(); } 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"); + alert("El nombre de la regla 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; + if (!this.selectedTriggerProducts.length) { + alert("Selecciona al menos un producto trigger"); + return; + } + + if (!this.selectedRecommendedProducts.length) { + alert("Selecciona al menos un producto para recomendar"); + return; + } const data = { rule_key: ruleKey, - trigger, - queries, - ask_slots, + trigger: {}, // Legacy field, keep empty + queries: [], // Legacy field, keep empty + ask_slots: [], active, priority, + trigger_product_ids: this.selectedTriggerProducts, + recommended_product_ids: this.selectedRecommendedProducts, }; try { @@ -312,6 +467,8 @@ class RecommendationsCrud extends HTMLElement { cancel() { this.editMode = null; this.selected = null; + this.selectedTriggerProducts = []; + this.selectedRecommendedProducts = []; this.renderList(); this.renderForm(); } diff --git a/public/components/run-timeline.js b/public/components/run-timeline.js index 2a2fade..6b45ae6 100644 --- a/public/components/run-timeline.js +++ b/public/components/run-timeline.js @@ -53,6 +53,8 @@ class RunTimeline extends HTMLElement { this.shadowRoot.getElementById("refresh").onclick = () => this.loadMessages(); this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => { + // Si es el mismo chat, no recargar (para no borrar burbujas optimistas) + if (this.chatId === chat_id) return; this.chatId = chat_id; await this.loadMessages(); }); @@ -76,7 +78,13 @@ class RunTimeline extends HTMLElement { // Listen for optimistic messages (show bubble immediately before API response) this._unsubOptimistic = on("message:optimistic", (msg) => { - if (!this.chatId || msg.chat_id !== this.chatId) return; + // Si no hay chatId seteado, setearlo al del mensaje + if (!this.chatId) { + this.chatId = msg.chat_id; + this.shadowRoot.getElementById("chat").textContent = msg.chat_id; + this.shadowRoot.getElementById("meta").textContent = "Nueva conversación"; + } + if (msg.chat_id !== this.chatId) return; this.addOptimisticBubble(msg); }); } @@ -135,8 +143,20 @@ class RunTimeline extends HTMLElement { meta.textContent = `Mostrando historial (últimos ${this.items.length}).`; count.textContent = this.items.length ? `${this.items.length} msgs` : ""; + // Capturar info de burbujas optimistas antes de limpiar + const optimisticBubbles = [...log.querySelectorAll('.bubble[data-message-id^="optimistic-"]')]; + const optimisticTexts = optimisticBubbles.map(b => { + const textEl = b.querySelector("div:not(.name):not(.meta)"); + return (textEl ? textEl.textContent : "").trim().toLowerCase(); + }); + log.innerHTML = ""; + // Obtener textos de mensajes IN del servidor (normalizados para comparación) + const serverUserTexts = this.items + .filter(m => m.direction === "in") + .map(m => (m.text || "").trim().toLowerCase()); + for (const m of this.items) { const who = m.direction === "in" ? "user" : "bot"; const isErr = this.isErrorMsg(m); @@ -164,8 +184,23 @@ class RunTimeline extends HTMLElement { log.appendChild(bubble); } - // auto-scroll - log.scrollTop = log.scrollHeight; + // Re-agregar burbujas optimistas SOLO si su texto no está ya en los mensajes del servidor + // Comparación case-insensitive y trimmed + let addedOptimistic = false; + for (let i = 0; i < optimisticBubbles.length; i++) { + const optText = optimisticTexts[i]; + // Si el texto ya existe en un mensaje del servidor, no re-agregar + if (serverUserTexts.includes(optText)) { + continue; + } + log.appendChild(optimisticBubbles[i]); + addedOptimistic = true; + } + + // auto-scroll solo si agregamos burbujas optimistas nuevas + if (addedOptimistic) { + log.scrollTop = log.scrollHeight; + } requestAnimationFrame(() => this.emitLayout()); this.bindScroll(log); @@ -193,29 +228,7 @@ class RunTimeline extends HTMLElement { }; }); emit("ui:bubblesLayout", { chat_id: this.chatId, items }); - // #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: "H15", - location: "run-timeline.js:180", - message: "bubbles_layout", - data: { - count: items.length, - chat_id: this.chatId || null, - scroll_height: log.scrollHeight, - client_height: log.clientHeight, - host_height: this.getBoundingClientRect().height, - box_height: box ? box.getBoundingClientRect().height : null, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - } +} highlightMessage(message_id) { const log = this.shadowRoot.getElementById("log"); @@ -275,7 +288,12 @@ class RunTimeline extends HTMLElement { bubble.appendChild(metaEl); log.appendChild(bubble); - log.scrollTop = log.scrollHeight; + + // Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px) + const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100; + if (wasNearBottom) { + log.scrollTop = log.scrollHeight; + } // Emit layout update requestAnimationFrame(() => this.emitLayout()); diff --git a/public/components/users-crud.js b/public/components/users-crud.js index 9c96a1c..311b625 100644 --- a/public/components/users-crud.js +++ b/public/components/users-crud.js @@ -9,6 +9,7 @@ class UsersCrud extends HTMLElement { this.selected = null; this.loading = false; this.searchQuery = ""; + this.wooFilter = false; this.shadowRoot.innerHTML = ` @@ -58,11 +61,11 @@ class UsersCrud extends HTMLElement {
Usuarios
-
+
Total
-
+
Con Woo ID
@@ -92,9 +95,29 @@ class UsersCrud extends HTMLElement { this._searchTimer = setTimeout(() => this.load(), 300); }; + // Stats click handlers + this.shadowRoot.getElementById("statTotal").onclick = () => { + this.wooFilter = false; + this.renderList(); + this.updateStatStyles(); + }; + + this.shadowRoot.getElementById("statWoo").onclick = () => { + this.wooFilter = !this.wooFilter; + this.renderList(); + this.updateStatStyles(); + }; + this.load(); } + updateStatStyles() { + const statTotal = this.shadowRoot.getElementById("statTotal"); + const statWoo = this.shadowRoot.getElementById("statWoo"); + statTotal.classList.toggle("active", !this.wooFilter); + statWoo.classList.toggle("active", this.wooFilter); + } + async load() { this.loading = true; this.renderList(); @@ -129,13 +152,18 @@ class UsersCrud extends HTMLElement { return; } - if (!this.items.length) { + // Filter by woo ID if filter is active + const filteredItems = this.wooFilter + ? this.items.filter(u => u.external_customer_id) + : this.items; + + if (!filteredItems.length) { list.innerHTML = `
No se encontraron usuarios
`; return; } list.innerHTML = ""; - for (const item of this.items) { + for (const item of filteredItems) { const el = document.createElement("div"); el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : ""); diff --git a/public/lib/api.js b/public/lib/api.js index 8e4a08b..d94fbd0 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -76,6 +76,30 @@ export const api = { return fetch("/products/sync", { method: "POST" }).then(r => r.json()); }, + async updateProductUnit(wooProductId, { sell_unit }) { + return fetch(`/products/${encodeURIComponent(wooProductId)}/unit`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sell_unit }), + }).then(r => r.json()); + }, + + async updateProduct(wooProductId, { sell_unit, categories }) { + return fetch(`/products/${encodeURIComponent(wooProductId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sell_unit, categories }), + }).then(r => r.json()); + }, + + async updateProductsUnit(wooProductIds, { sell_unit }) { + return fetch(`/products/bulk/unit`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ woo_product_ids: wooProductIds, sell_unit }), + }).then(r => r.json()); + }, + // Aliases CRUD async aliases({ q = "", woo_product_id = null, limit = 200 } = {}) { const u = new URL("/aliases", location.origin); diff --git a/src/modules/0-ui/controllers/products.js b/src/modules/0-ui/controllers/products.js index 6c0c244..69e030c 100644 --- a/src/modules/0-ui/controllers/products.js +++ b/src/modules/0-ui/controllers/products.js @@ -1,4 +1,4 @@ -import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts } from "../handlers/products.js"; +import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts, handleUpdateProductUnit, handleBulkUpdateProductUnit, handleUpdateProduct } from "../handlers/products.js"; export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => { try { @@ -54,3 +54,60 @@ export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => { } }; +export const makeUpdateProductUnit = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const wooProductId = req.params.id; + const { sell_unit } = req.body || {}; + + if (!sell_unit || !["kg", "unit"].includes(sell_unit)) { + return res.status(400).json({ ok: false, error: "invalid_sell_unit" }); + } + + const result = await handleUpdateProductUnit({ tenantId, wooProductId, sell_unit }); + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ ok: false, error: "internal_error" }); + } +}; + +export const makeBulkUpdateProductUnit = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const { woo_product_ids, sell_unit } = req.body || {}; + + if (!sell_unit || !["kg", "unit"].includes(sell_unit)) { + return res.status(400).json({ ok: false, error: "invalid_sell_unit" }); + } + + if (!Array.isArray(woo_product_ids) || !woo_product_ids.length) { + return res.status(400).json({ ok: false, error: "invalid_woo_product_ids" }); + } + + const result = await handleBulkUpdateProductUnit({ tenantId, wooProductIds: woo_product_ids, sell_unit }); + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ ok: false, error: "internal_error" }); + } +}; + +export const makeUpdateProduct = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const wooProductId = req.params.id; + const { sell_unit, categories } = req.body || {}; + + if (sell_unit && !["kg", "unit"].includes(sell_unit)) { + return res.status(400).json({ ok: false, error: "invalid_sell_unit" }); + } + + const result = await handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories }); + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ ok: false, error: "internal_error" }); + } +}; + diff --git a/src/modules/0-ui/db/repo.js b/src/modules/0-ui/db/repo.js index b1f20e8..13917c8 100644 --- a/src/modules/0-ui/db/repo.js +++ b/src/modules/0-ui/db/repo.js @@ -22,7 +22,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 categories, attributes_normalized, updated_at as refreshed_at, - raw as payload + raw as payload, + raw->>'_sell_unit_override' as sell_unit from woo_products_snapshot where tenant_id = $1 and (name ilike $2 or coalesce(slug,'') ilike $2) @@ -41,7 +42,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 categories, attributes_normalized, updated_at as refreshed_at, - raw as payload + raw as payload, + raw->>'_sell_unit_override' as sell_unit from woo_products_snapshot where tenant_id = $1 order by name asc @@ -65,7 +67,8 @@ export async function getProductByWooId({ tenantId, wooProductId }) { categories, attributes_normalized, updated_at as refreshed_at, - raw as payload + raw as payload, + raw->>'_sell_unit_override' as sell_unit from woo_products_snapshot where tenant_id = $1 and woo_id = $2 limit 1 @@ -74,6 +77,66 @@ export async function getProductByWooId({ tenantId, wooProductId }) { return rows[0] || null; } +export async function updateProductSellUnit({ tenantId, wooProductId, sell_unit }) { + const sql = ` + update woo_products_snapshot + set raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $3::jsonb) + where tenant_id = $1 and woo_id = $2 + returning woo_id as woo_product_id + `; + const { rows } = await pool.query(sql, [tenantId, wooProductId, JSON.stringify(sell_unit)]); + return rows[0] || null; +} + +export async function bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_unit }) { + if (!wooProductIds || !wooProductIds.length) return { updated: 0 }; + + const sql = ` + update woo_products_snapshot + set raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $3::jsonb) + where tenant_id = $1 and woo_id = ANY($2::int[]) + `; + const result = await pool.query(sql, [tenantId, wooProductIds, JSON.stringify(sell_unit)]); + return { updated: result.rowCount }; +} + +export async function updateProduct({ tenantId, wooProductId, sell_unit, categories }) { + // Build the JSONB update dynamically + let updates = []; + let params = [tenantId, wooProductId]; + let paramIdx = 3; + + if (sell_unit) { + updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_sell_unit_override}', $${paramIdx}::jsonb)`); + params.push(JSON.stringify(sell_unit)); + paramIdx++; + } + + if (categories) { + // Also update the categories column if it exists + updates.push(`categories = $${paramIdx}::jsonb`); + params.push(JSON.stringify(categories.map(name => ({ name })))); + paramIdx++; + + // Also store in raw for persistence + updates.push(`raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{_categories_override}', $${paramIdx}::jsonb)`); + params.push(JSON.stringify(categories)); + paramIdx++; + } + + if (!updates.length) return null; + + const sql = ` + update woo_products_snapshot + set ${updates.join(", ")} + where tenant_id = $1 and woo_id = $2 + returning woo_id as woo_product_id + `; + + const { rows } = await pool.query(sql, params); + return rows[0] || null; +} + // ───────────────────────────────────────────────────────────── // Aliases // ───────────────────────────────────────────────────────────── @@ -188,7 +251,8 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) { if (query) { const like = `%${query}%`; sql = ` - select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, + trigger_product_ids, recommended_product_ids, created_at, updated_at from product_reco_rules where tenant_id = $1 and rule_key ilike $2 order by priority desc, rule_key asc @@ -197,7 +261,8 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) { params = [tenantId, like, lim]; } else { sql = ` - select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, + trigger_product_ids, recommended_product_ids, created_at, updated_at from product_reco_rules where tenant_id = $1 order by priority desc, rule_key asc @@ -212,7 +277,8 @@ export async function listRecommendations({ tenantId, q = "", limit = 200 }) { export async function getRecommendationById({ tenantId, id }) { const sql = ` - select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, + trigger_product_ids, recommended_product_ids, created_at, updated_at from product_reco_rules where tenant_id = $1 and id = $2 limit 1 @@ -230,11 +296,13 @@ export async function insertRecommendation({ ask_slots = [], active = true, priority = 100, + trigger_product_ids = [], + recommended_product_ids = [], }) { const sql = ` - insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority) - values ($1, $2, $3, $4, $5, $6, $7, $8) - returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, created_at, updated_at `; const { rows } = await pool.query(sql, [ @@ -246,6 +314,8 @@ export async function insertRecommendation({ JSON.stringify(ask_slots || []), active !== false, priority || 100, + trigger_product_ids || [], + recommended_product_ids || [], ]); return rows[0]; @@ -260,6 +330,8 @@ export async function updateRecommendation({ ask_slots, active, priority, + trigger_product_ids, + recommended_product_ids, }) { const sql = ` update product_reco_rules @@ -270,9 +342,11 @@ export async function updateRecommendation({ ask_slots = $6, active = $7, priority = $8, + trigger_product_ids = $9, + recommended_product_ids = $10, updated_at = now() where tenant_id = $1 and id = $2 - returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids, created_at, updated_at `; const { rows } = await pool.query(sql, [ @@ -284,6 +358,8 @@ export async function updateRecommendation({ JSON.stringify(ask_slots || []), active !== false, priority || 100, + trigger_product_ids || [], + recommended_product_ids || [], ]); return rows[0] || null; diff --git a/src/modules/0-ui/handlers/products.js b/src/modules/0-ui/handlers/products.js index 6c26ed3..13d97d4 100644 --- a/src/modules/0-ui/handlers/products.js +++ b/src/modules/0-ui/handlers/products.js @@ -1,5 +1,5 @@ import { searchSnapshotItems } from "../../shared/wooSnapshot.js"; -import { listProducts, getProductByWooId } from "../db/repo.js"; +import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct } from "../db/repo.js"; export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) { const { items, source } = await searchSnapshotItems({ @@ -25,3 +25,18 @@ export async function handleSyncProducts({ tenantId }) { return { ok: true, message: "Sync triggered (use import script for full sync)" }; } +export async function handleUpdateProductUnit({ tenantId, wooProductId, sell_unit }) { + await updateProductSellUnit({ tenantId, wooProductId, sell_unit }); + return { ok: true, woo_product_id: wooProductId, sell_unit }; +} + +export async function handleBulkUpdateProductUnit({ tenantId, wooProductIds, sell_unit }) { + await bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_unit }); + return { ok: true, updated_count: wooProductIds.length, sell_unit }; +} + +export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories }) { + await updateProduct({ tenantId, wooProductId, sell_unit, categories }); + return { ok: true, woo_product_id: wooProductId, sell_unit, categories }; +} + diff --git a/src/modules/0-ui/handlers/recommendations.js b/src/modules/0-ui/handlers/recommendations.js index 1e19089..482c89c 100644 --- a/src/modules/0-ui/handlers/recommendations.js +++ b/src/modules/0-ui/handlers/recommendations.js @@ -24,8 +24,10 @@ export async function handleCreateRecommendation({ ask_slots = [], active = true, priority = 100, + trigger_product_ids = [], + recommended_product_ids = [], }) { - return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority }); + return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids }); } export async function handleUpdateRecommendation({ @@ -37,8 +39,10 @@ export async function handleUpdateRecommendation({ ask_slots, active, priority, + trigger_product_ids, + recommended_product_ids, }) { - return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority }); + return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority, trigger_product_ids, recommended_product_ids }); } export async function handleDeleteRecommendation({ tenantId, id }) { diff --git a/src/modules/1-intake/handlers/evolution.js b/src/modules/1-intake/handlers/evolution.js index c383915..bee02d4 100644 --- a/src/modules/1-intake/handlers/evolution.js +++ b/src/modules/1-intake/handlers/evolution.js @@ -6,27 +6,7 @@ import { debug as dbg } from "../../shared/debug.js"; export async function handleEvolutionWebhook(body) { const t0 = Date.now(); const parsed = parseEvolutionWebhook(body); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H1", - location: "evolution.js:9", - message: "parsed_webhook", - data: { - ok: parsed?.ok, - reason: parsed?.reason || null, - has_text: Boolean(parsed?.text), - source: parsed?.source || null, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - if (!parsed.ok) { +if (!parsed.ok) { return { status: 200, payload: { ok: true, ignored: parsed.reason } }; } diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index f891a66..1f0ca08 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -6,7 +6,7 @@ import { makeListRuns, makeGetRunById } from "../../0-ui/controllers/runs.js"; import { makeSimSend } from "../controllers/sim.js"; import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js"; import { makeListMessages } from "../../0-ui/controllers/messages.js"; -import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts } from "../../0-ui/controllers/products.js"; +import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } from "../../0-ui/controllers/products.js"; import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js"; import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js"; @@ -53,8 +53,11 @@ export function createSimulatorRouter({ tenantId }) { router.get("/messages", makeListMessages(getTenantId)); router.get("/products", makeListProducts(getTenantId)); router.get("/products/search", makeSearchProducts(getTenantId)); - router.get("/products/:id", makeGetProduct(getTenantId)); + router.patch("/products/bulk/unit", makeBulkUpdateProductUnit(getTenantId)); router.post("/products/sync", makeSyncProducts(getTenantId)); + router.get("/products/:id", makeGetProduct(getTenantId)); + router.patch("/products/:id/unit", makeUpdateProductUnit(getTenantId)); + router.patch("/products/:id", makeUpdateProduct(getTenantId)); router.get("/aliases", makeListAliases(getTenantId)); router.post("/aliases", makeCreateAlias(getTenantId)); diff --git a/src/modules/2-identity/db/repo.js b/src/modules/2-identity/db/repo.js index 1ba27f8..71873ce 100644 --- a/src/modules/2-identity/db/repo.js +++ b/src/modules/2-identity/db/repo.js @@ -565,7 +565,8 @@ export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) { export async function getRecoRules({ tenant_id }) { const sql = ` - select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, + trigger_product_ids, recommended_product_ids, created_at, updated_at from product_reco_rules where tenant_id=$1 and active=true order by priority asc, id asc @@ -574,9 +575,26 @@ export async function getRecoRules({ tenant_id }) { return rows; } +/** + * Buscar reglas que tengan alguno de los productos como trigger. + */ +export async function getRecoRulesByProductIds({ tenant_id, product_ids = [] }) { + if (!product_ids?.length) return []; + const sql = ` + select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, + trigger_product_ids, recommended_product_ids, created_at, updated_at + from product_reco_rules + where tenant_id=$1 and active=true and trigger_product_ids && $2::int[] + order by priority asc, id asc + `; + const { rows } = await pool.query(sql, [tenant_id, product_ids]); + return rows; +} + export async function getRecoRuleByKey({ tenant_id, rule_key }) { const sql = ` - select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at + select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, + trigger_product_ids, recommended_product_ids, created_at, updated_at from product_reco_rules where tenant_id=$1 and rule_key=$2 limit 1 diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index a08da75..17e494d 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -123,28 +123,7 @@ export async function processMessage({ meta = null, }) { const { started_at, mark, msBetween } = makePerf(); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H2", - location: "pipeline.js:128", - message: "processMessage_enter", - data: { - tenantId: tenantId || null, - provider, - chat_id: chat_id || null, - text_len: String(text || "").length, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - - const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id }); +const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id }); mark("start"); const stageDebug = dbg.perf; @@ -153,27 +132,7 @@ export async function processMessage({ prev?.state_updated_at && Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H3", - location: "pipeline.js:150", - message: "conversation_state_loaded", - data: { - prev_state, - isStale: Boolean(isStale), - state_updated_at: prev?.state_updated_at || null, - has_context: Boolean(prev?.context && typeof prev?.context === "object"), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - let externalCustomerId = await getExternalCustomerIdByChat({ +let externalCustomerId = await getExternalCustomerIdByChat({ tenant_id: tenantId, wa_chat_id: chat_id, provider: "woo", @@ -203,6 +162,9 @@ export async function processMessage({ logStage(stageDebug, "history", { has_history: Array.isArray(conversation_history), state: prev_state }); let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {}; + // #region agent log + fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({location:"pipeline.js:164",message:"pipeline_loaded_context",data:{prev_state,has_prev_context:!!prev?.context,reducedContext_has_order_basket:!!reducedContext?.order_basket,reducedContext_basket_count:reducedContext?.order_basket?.items?.length||0,reducedContext_basket_labels:(reducedContext?.order_basket?.items||[]).map(i=>i.label)},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H3-H4"})}).catch(()=>{}); + // #endregion let decision; let plan; let llmMeta; @@ -222,28 +184,7 @@ export async function processMessage({ llmMeta = { kind: "nlu_v3", audit: decision.audit || null }; tools = []; mark("after_turn_v3"); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H4", - location: "pipeline.js:198", - message: "turn_v3_result", - data: { - intent: plan?.intent || null, - next_state: plan?.next_state || null, - missing_fields: Array.isArray(plan?.missing_fields) ? plan.missing_fields.length : null, - actions_count: Array.isArray(decision?.actions) ? decision.actions.length : null, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - - const runStatus = llmMeta?.error ? "warn" : "ok"; +const runStatus = llmMeta?.error ? "warn" : "ok"; const isSimulated = provider === "sim" || meta?.source === "sim"; const invariants = { @@ -401,6 +342,10 @@ export async function processMessage({ woo_customer_error: wooCustomerError, }; + // #region agent log + fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({location:"pipeline.js:345",message:"pipeline_saving_context",data:{reducedContext_basket_count:reducedContext?.order_basket?.items?.length||0,context_patch_basket_count:decision?.context_patch?.order_basket?.items?.length||0,final_context_basket_count:context?.order_basket?.items?.length||0,final_context_basket_labels:(context?.order_basket?.items||[]).map(i=>i.label),plan_intent:plan?.intent},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H2-H4"})}).catch(()=>{}); + // #endregion + const nextState = safeNextState(prev_state, context, { requested_checkout: plan.intent === "checkout" }).next_state; plan.next_state = nextState; @@ -429,6 +374,9 @@ export async function processMessage({ await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms }); } + // Incluir carrito completo para la UI + const fullBasket = context?.order_basket?.items || []; + sseSend("run.created", { run_id, ts: nowIso(), @@ -437,7 +385,7 @@ export async function processMessage({ status: runStatus, prev_state, input: { text }, - llm_output: { ...plan, _llm: llmMeta }, + llm_output: { ...plan, _llm: llmMeta, full_basket: { items: fullBasket } }, tools, invariants, final_reply: plan.reply, diff --git a/src/modules/3-turn-engine/catalogRetrieval.js b/src/modules/3-turn-engine/catalogRetrieval.js index c6778bc..b5639a9 100644 --- a/src/modules/3-turn-engine/catalogRetrieval.js +++ b/src/modules/3-turn-engine/catalogRetrieval.js @@ -1,7 +1,7 @@ import crypto from "crypto"; import OpenAI from "openai"; import { debug as dbg } from "../shared/debug.js"; -import { searchSnapshotItems } from "../shared/wooSnapshot.js"; +import { searchSnapshotItems, getSnapshotItemsByIds } from "../shared/wooSnapshot.js"; import { searchProductAliases, getProductEmbedding, @@ -137,48 +137,53 @@ export async function retrieveCandidates({ const audit = { query: q, sources: {}, boosts: {}, embeddings: {} }; + // 1) Buscar aliases que matcheen la query const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 }); const aliasBoostByProduct = new Map(); + const aliasProductIds = new Set(); for (const a of aliases) { if (a?.woo_product_id) { const id = Number(a.woo_product_id); const boost = Number(a.boost || 0); aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0)); + aliasProductIds.add(id); } } audit.sources.aliases = aliases.length; + // 2) Buscar productos por nombre/slug (búsqueda literal) const { items: wooItems, source: wooSource } = await searchSnapshotItems({ tenantId, q, limit: lim, }); audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 }; - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H9", - location: "catalogRetrieval.js:158", - message: "catalog_sources", - data: { - query: q, - aliases_count: aliases.length, - snapshot_count: wooItems?.length || 0, - snapshot_source: wooSource || null, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - - let candidates = (wooItems || []).map((c) => { + + // 3) Traer productos que matchearon por alias pero no por búsqueda literal + const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id))); + const missingAliasIds = [...aliasProductIds].filter(id => !foundIds.has(id)); + let aliasItems = []; + if (missingAliasIds.length > 0) { + const { items: fromAlias } = await getSnapshotItemsByIds({ + tenantId, + wooProductIds: missingAliasIds, + }); + aliasItems = fromAlias || []; + audit.sources.alias_products = aliasItems.length; + } +// 4) Combinar productos de búsqueda literal + productos de aliases + const allItems = [...(wooItems || []), ...aliasItems]; + + let candidates = allItems.map((c) => { const lit = literalScore(q, c); const boost = aliasBoostByProduct.get(Number(c.woo_product_id)) || 0; - return { ...c, _score: lit + boost, _score_detail: { literal: lit, alias_boost: boost } }; + // Productos encontrados solo por alias tienen lit=0 pero boost alto + const finalScore = lit + boost + (aliasProductIds.has(Number(c.woo_product_id)) && lit < 0.3 ? 0.5 : 0); + return { + ...c, + _score: finalScore, + _score_detail: { literal: lit, alias_boost: boost, from_alias: aliasProductIds.has(Number(c.woo_product_id)) } + }; }); // embeddings: opcional, si hay key y tenemos candidatos diff --git a/src/modules/3-turn-engine/fsm.js b/src/modules/3-turn-engine/fsm.js index 6051750..995dca4 100644 --- a/src/modules/3-turn-engine/fsm.js +++ b/src/modules/3-turn-engine/fsm.js @@ -10,8 +10,11 @@ export const ConversationState = Object.freeze({ IDLE: "IDLE", BROWSING: "BROWSING", + CLARIFYING_ITEMS: "CLARIFYING_ITEMS", // Clarificando items pendientes uno por uno AWAITING_QUANTITY: "AWAITING_QUANTITY", CART_ACTIVE: "CART_ACTIVE", + CLARIFYING_PAYMENT: "CLARIFYING_PAYMENT", // Preguntando método de pago (efectivo/link) + CLARIFYING_SHIPPING: "CLARIFYING_SHIPPING", // Preguntando delivery o retiro AWAITING_ADDRESS: "AWAITING_ADDRESS", AWAITING_PAYMENT: "AWAITING_PAYMENT", COMPLETED: "COMPLETED", @@ -34,6 +37,16 @@ function hasPendingItem(ctx) { return Boolean(ctx?.pending_item?.product_id || ctx?.pending_item?.sku); } +/** + * Verifica si hay items pendientes de clarificar (nuevo modelo acumulativo). + * Un item pendiente tiene status "needs_type" o "needs_quantity". + */ +function hasPendingItems(ctx) { + const items = ctx?.pending_items; + if (!Array.isArray(items) || items.length === 0) return false; + return items.some(i => i.status === "needs_type" || i.status === "needs_quantity"); +} + function hasAddress(ctx) { return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text); } @@ -55,6 +68,34 @@ function isPaid(ctx) { return st === "approved" || st === "paid"; } +/** + * Verifica si estamos clarificando método de pago. + */ +function isClarifyingPayment(ctx) { + return ctx?.checkout_step === "payment_method"; +} + +/** + * Verifica si estamos clarificando shipping (delivery/retiro). + */ +function isClarifyingShipping(ctx) { + return ctx?.checkout_step === "shipping_method"; +} + +/** + * Verifica si ya se eligió método de pago. + */ +function hasPaymentMethod(ctx) { + return Boolean(ctx?.payment_method); // "cash" | "link" +} + +/** + * Verifica si ya se eligió método de envío. + */ +function hasShippingMethod(ctx) { + return Boolean(ctx?.shipping_method); // "delivery" | "pickup" +} + /** * Deriva el estado objetivo según el contexto actual y señales del turno. * `signals` es información determinística del motor del turno (no del LLM), @@ -67,20 +108,35 @@ export function deriveNextState(prevState, ctx = {}, signals = {}) { // Regla 2: si ya existe orden + link de pago, estamos esperando pago if (hasWooOrder(ctx) && hasPaymentLink(ctx)) return ConversationState.AWAITING_PAYMENT; - // Regla 3: si intentó checkout pero falta dirección - if ((signals.requested_checkout || signals.requested_address) && hasBasketItems(ctx) && !hasAddress(ctx)) { + // Regla 3: si estamos clarificando método de pago + if (isClarifyingPayment(ctx)) { + return ConversationState.CLARIFYING_PAYMENT; + } + + // Regla 4: si estamos clarificando shipping + if (isClarifyingShipping(ctx)) { + return ConversationState.CLARIFYING_SHIPPING; + } + + // Regla 5: si intentó checkout, tiene shipping=delivery, pero falta dirección + if (signals.requested_address || (hasShippingMethod(ctx) && ctx.shipping_method === "delivery" && !hasAddress(ctx))) { return ConversationState.AWAITING_ADDRESS; } - // Regla 4: si hay item pendiente sin completar cantidad + // Regla 6: si hay items pendientes de clarificar (nuevo modelo acumulativo) + if (hasPendingItems(ctx)) { + return ConversationState.CLARIFYING_ITEMS; + } + + // Regla 7: si hay item pendiente sin completar cantidad (modelo legacy) if (hasPendingItem(ctx) && !signals.pending_item_completed) { return ConversationState.AWAITING_QUANTITY; } - // Regla 5: si hay carrito activo + // Regla 8: si hay carrito activo if (hasBasketItems(ctx)) return ConversationState.CART_ACTIVE; - // Regla 6: si estamos mostrando opciones / esperando selección + // Regla 9: si estamos mostrando opciones / esperando selección (modelo legacy) if (hasPendingClarification(ctx) || signals.did_show_options || signals.is_browsing) { return ConversationState.BROWSING; } @@ -92,30 +148,55 @@ const ALLOWED = Object.freeze({ [ConversationState.IDLE]: [ ConversationState.IDLE, ConversationState.BROWSING, + ConversationState.CLARIFYING_ITEMS, ConversationState.AWAITING_QUANTITY, ConversationState.CART_ACTIVE, ConversationState.ERROR_RECOVERY, ], [ConversationState.BROWSING]: [ ConversationState.BROWSING, + ConversationState.CLARIFYING_ITEMS, ConversationState.AWAITING_QUANTITY, ConversationState.CART_ACTIVE, ConversationState.IDLE, ConversationState.ERROR_RECOVERY, ], + [ConversationState.CLARIFYING_ITEMS]: [ + ConversationState.CLARIFYING_ITEMS, + ConversationState.CART_ACTIVE, + ConversationState.BROWSING, + ConversationState.IDLE, + ConversationState.ERROR_RECOVERY, + ], [ConversationState.AWAITING_QUANTITY]: [ ConversationState.AWAITING_QUANTITY, + ConversationState.CLARIFYING_ITEMS, ConversationState.CART_ACTIVE, ConversationState.BROWSING, ConversationState.ERROR_RECOVERY, ], [ConversationState.CART_ACTIVE]: [ ConversationState.CART_ACTIVE, + ConversationState.CLARIFYING_ITEMS, + ConversationState.CLARIFYING_PAYMENT, ConversationState.AWAITING_ADDRESS, ConversationState.AWAITING_PAYMENT, ConversationState.ERROR_RECOVERY, ConversationState.BROWSING, ], + [ConversationState.CLARIFYING_PAYMENT]: [ + ConversationState.CLARIFYING_PAYMENT, + ConversationState.CLARIFYING_SHIPPING, + ConversationState.CART_ACTIVE, // Volver si cancela + ConversationState.ERROR_RECOVERY, + ], + [ConversationState.CLARIFYING_SHIPPING]: [ + ConversationState.CLARIFYING_SHIPPING, + ConversationState.AWAITING_ADDRESS, // Si elige delivery + ConversationState.AWAITING_PAYMENT, // Si elige retiro (directo a crear orden) + ConversationState.CLARIFYING_PAYMENT, // Volver a cambiar pago + ConversationState.ERROR_RECOVERY, + ], [ConversationState.AWAITING_ADDRESS]: [ ConversationState.AWAITING_ADDRESS, ConversationState.AWAITING_PAYMENT, diff --git a/src/modules/3-turn-engine/openai.js b/src/modules/3-turn-engine/openai.js index ffb9c8a..1a559d5 100644 --- a/src/modules/3-turn-engine/openai.js +++ b/src/modules/3-turn-engine/openai.js @@ -75,7 +75,7 @@ const NluV3JsonSchema = { properties: { intent: { type: "string", - enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "recommend", "other"], + enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_payment", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"], }, confidence: { type: "number", minimum: 0, maximum: 1 }, language: { type: "string" }, @@ -103,6 +103,10 @@ const NluV3JsonSchema = { }, attributes: { type: "array", items: { type: "string" } }, preparation: { type: "array", items: { type: "string" } }, + // Checkout: método de pago, envío, dirección + payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] }, + shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, + address: { anyOf: [{ type: "string" }, { type: "null" }] }, // Soporte para múltiples productos en un mensaje items: { anyOf: [ @@ -231,6 +235,10 @@ function normalizeNluOutput(parsed, input) { selection: entities.selection ?? null, attributes: Array.isArray(entities.attributes) ? entities.attributes : [], preparation: Array.isArray(entities.preparation) ? entities.preparation : [], + // Checkout entities (opcionales) + payment_method: entities.payment_method ?? null, + shipping_method: entities.shipping_method ?? null, + address: entities.address ?? null, items: normalizedItems, }; @@ -250,27 +258,7 @@ function normalizeNluOutput(parsed, input) { const canInfer = hasShownOptions && !hasPendingItem; const inferred = canInfer ? inferSelectionFromText(input?.last_user_message) : null; out.entities.selection = inferred || null; - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H11", - location: "openai.js:129", - message: "selection_inferred", - data: { - inferred: Boolean(inferred), - pending_item: hasPendingItem, - has_shown_options: hasShownOptions, - text: String(input?.last_user_message || "").slice(0, 20), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - } +} } out.needs = { @@ -293,6 +281,9 @@ function nluV3Fallback() { selection: null, attributes: [], preparation: [], + payment_method: null, + shipping_method: null, + address: null, items: null, }, needs: { catalog_lookup: false, knowledge_lookup: false }, @@ -322,7 +313,15 @@ export async function llmNluV3({ input, model } = {}) { "- selection SOLO aplica cuando hay opciones visibles para seleccionar (last_shown_options tiene elementos).\n" + "- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" + "- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n" + - "- Si el usuario pide recomendación/sugerencias, usá intent='recommend' y needs.catalog_lookup=true.\n" + + "- PREGUNTAS SOBRE DISPONIBILIDAD: Si el usuario pregunta si hay/venden/tienen un producto (ej: 'vendés vino?', 'tenés chimichurri?', 'hay provoleta?'), usá intent='browse' con product_query=ese producto. needs.catalog_lookup=true.\n" + + "- RECOMENDACIONES: SOLO usá intent='recommend' si el usuario pide sugerencias SIN mencionar ningún producto (ej: 'qué me recomendás?', 'qué me sugerís?'). Si menciona CUALQUIER producto, usá intent='add_to_cart' con product_query=ese producto. Ejemplos que son add_to_cart: 'me recomendás un vino?', 'recomendame un vino', 'qué vino me recomendás?', 'tenés algún vino bueno?' → TODOS son add_to_cart con product_query='vino'.\n" + + "- COMPRAR/PEDIR PRODUCTOS: Si el usuario quiere comprar/pedir/llevar productos (ej: 'quiero comprar X', 'quiero X', 'dame X', 'necesito X', 'anotame X'), usá intent='add_to_cart'. needs.catalog_lookup=true. Aunque incluya un saludo o pida recomendación, si menciona productos específicos es add_to_cart.\n" + + "- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" + + "- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" + + "- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" + + "- SELECCIONAR PAGO: Si el usuario elige método de pago (ej: 'efectivo', 'tarjeta', 'link de pago', 'transferencia'), usá intent='select_payment'. Extraer entities.payment_method='cash'|'link'.\n" + + "- SELECCIONAR ENVÍO: Si el usuario elige envío (ej: 'delivery', 'envío', 'que me lo traigan', 'retiro', 'paso a buscar'), usá intent='select_shipping'. Extraer entities.shipping_method='delivery'|'pickup'.\n" + + "- DAR DIRECCIÓN: Si el usuario da una dirección de entrega, usá intent='provide_address'. Extraer entities.address con el texto de la dirección.\n" + "- MULTI-ITEMS: Si el usuario menciona MÚLTIPLES productos en un mensaje (ej: '1 chimichurri y 2 provoletas'), usá entities.items con array de objetos.\n" + " Ejemplo: items:[{product_query:'chimichurri',quantity:1,unit:null},{product_query:'provoletas',quantity:2,unit:null}]\n" + " En este caso, product_query/quantity/unit del nivel superior quedan null.\n" + @@ -351,53 +350,14 @@ export async function llmNluV3({ input, model } = {}) { // intento 1 const first = await jsonCompletion({ system: systemBase, user, model }); - const firstNormalized = normalizeNluOutput(first.parsed, input); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H10", - location: "openai.js:196", - message: "nlu_normalized_first", - data: { - intent: firstNormalized?.intent || null, - unit: firstNormalized?.entities?.unit || null, - selection: firstNormalized?.entities?.selection ? "set" : "null", - needs_catalog: Boolean(firstNormalized?.needs?.catalog_lookup), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - if (validateNluV3(firstNormalized)) { +const firstNormalized = normalizeNluOutput(first.parsed, input); +const validationResult = validateNluV3(firstNormalized); +if (validationResult) { return { nlu: firstNormalized, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } }; } const errors1 = nluV3Errors(); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H7", - location: "openai.js:169", - message: "nlu_validation_failed_first", - data: { - errors_count: Array.isArray(errors1) ? errors1.length : null, - errors: Array.isArray(errors1) ? errors1.slice(0, 5) : null, - parsed_keys: first?.parsed ? Object.keys(first.parsed) : null, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - - // retry 1 vez +// retry 1 vez const systemRetry = systemBase + "\nTu respuesta anterior no validó el JSON Schema. Corregí el JSON para que cumpla estrictamente.\n" + @@ -406,50 +366,11 @@ export async function llmNluV3({ input, model } = {}) { try { const second = await jsonCompletion({ system: systemRetry, user, model }); const secondNormalized = normalizeNluOutput(second.parsed, input); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H10", - location: "openai.js:242", - message: "nlu_normalized_retry", - data: { - intent: secondNormalized?.intent || null, - unit: secondNormalized?.entities?.unit || null, - selection: secondNormalized?.entities?.selection ? "set" : "null", - needs_catalog: Boolean(secondNormalized?.needs?.catalog_lookup), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - if (validateNluV3(secondNormalized)) { +if (validateNluV3(secondNormalized)) { return { nlu: secondNormalized, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } }; } const errors2 = nluV3Errors(); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H7", - location: "openai.js:187", - message: "nlu_validation_failed_retry", - data: { - errors_count: Array.isArray(errors2) ? errors2.length : null, - errors: Array.isArray(errors2) ? errors2.slice(0, 5) : null, - parsed_keys: second?.parsed ? Object.keys(second.parsed) : null, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - return { +return { nlu: nluV3Fallback(), raw_text: second.raw_text, model: second.model, @@ -517,5 +438,3 @@ export async function llmRecommendWriter({ validation: { ok: false, errors: validateRecommendWriter.errors || [] }, }; } - -// Legacy llmPlan/llmExtract y NLU v2 removidos. diff --git a/src/modules/3-turn-engine/recommendations.js b/src/modules/3-turn-engine/recommendations.js index 1ce39d1..bb3dd3f 100644 --- a/src/modules/3-turn-engine/recommendations.js +++ b/src/modules/3-turn-engine/recommendations.js @@ -1,102 +1,33 @@ -import { getRecoRules } from "../2-identity/db/repo.js"; -import { retrieveCandidates } from "./catalogRetrieval.js"; +import { getRecoRules, getRecoRulesByProductIds } from "../2-identity/db/repo.js"; +import { getSnapshotItemsByIds } from "../shared/wooSnapshot.js"; import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js"; -import { llmRecommendWriter } from "./openai.js"; -function normalizeText(s) { - return String(s || "") - .toLowerCase() - .replace(/[¿?¡!.,;:()"]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function parseYesNo(text) { - const t = normalizeText(text); - if (!t) return null; - if (/\b(si|sí|sisi|dale|ok|claro|obvio|tomo)\b/.test(t)) return true; - if (/\b(no|nop|nunca|nope|sin alcohol)\b/.test(t)) return false; - return null; -} - -function pickBaseItem({ prev_context, basket_items }) { - const pending = prev_context?.pending_item; - if (pending?.name) { - return { - product_id: pending.product_id || null, - name: pending.name, - label: pending.name, - categories: pending.categories || [], - }; - } +/** + * Extrae los IDs de productos del carrito. + */ +function getBasketProductIds(basket_items) { const items = Array.isArray(basket_items) ? basket_items : []; - const last = items[items.length - 1]; - if (!last) return null; - return { - product_id: last.product_id || null, - name: last.label || last.name || "ese producto", - label: last.label || last.name || "ese producto", - categories: last.categories || [], - }; + return items + .map(item => item.product_id || item.woo_product_id) + .filter(id => id != null) + .map(Number); } -function ruleMatchesBase({ rule, base_item, slots }) { - const trigger = rule?.trigger && typeof rule.trigger === "object" ? rule.trigger : {}; - const text = normalizeText(base_item?.name || base_item?.label || ""); - const categories = Array.isArray(base_item?.categories) ? base_item.categories.map((c) => normalizeText(c?.name || c)) : []; - const keywords = Array.isArray(trigger.keywords) ? trigger.keywords.map(normalizeText).filter(Boolean) : []; - const cats = Array.isArray(trigger.categories) ? trigger.categories.map(normalizeText).filter(Boolean) : []; - const always = Boolean(trigger.always); - if (typeof trigger.alcohol === "boolean") { - if (slots?.alcohol == null) return false; - if (slots.alcohol !== trigger.alcohol) return false; - } - if (always) return true; - if (keywords.length && keywords.some((k) => text.includes(k))) return true; - if (cats.length && categories.some((c) => cats.includes(c))) return true; - return false; -} - -function collectAskSlots(rules) { - const out = []; - for (const r of rules) { - const ask = Array.isArray(r.ask_slots) ? r.ask_slots : []; - for (const slot of ask) { - if (slot && slot.slot) out.push(slot); - } - } - return out; -} - -function collectQueries({ rules, slots }) { - const out = []; - for (const r of rules) { - const q = Array.isArray(r.queries) ? r.queries : []; - for (const item of q) { - if (!item || typeof item !== "string") continue; - if (item.includes("{alcohol}")) { - const v = slots?.alcohol; - if (v == null) continue; - out.push(item.replace("{alcohol}", v ? "si" : "no")); - continue; +/** + * Obtiene los IDs de productos recomendados de las reglas que matchean. + */ +function collectRecommendedIds(rules, excludeIds = []) { + const excludeSet = new Set(excludeIds); + const ids = new Set(); + for (const rule of rules) { + const recoIds = Array.isArray(rule.recommended_product_ids) ? rule.recommended_product_ids : []; + for (const id of recoIds) { + if (!excludeSet.has(id)) { + ids.add(id); } - out.push(item); } } - return out.map((x) => x.trim()).filter(Boolean); -} - -function mergeCandidates({ lists, excludeId }) { - const map = new Map(); - for (const list of lists) { - for (const c of list || []) { - const id = Number(c?.woo_product_id); - if (!id || (excludeId && id === excludeId)) continue; - const prev = map.get(id); - if (!prev || (c._score || 0) > (prev._score || 0)) map.set(id, c); - } - } - return [...map.values()].sort((a, b) => (b._score || 0) - (a._score || 0)); + return [...ids]; } export async function handleRecommend({ @@ -106,14 +37,16 @@ export async function handleRecommend({ basket_items = [], limit = 9, } = {}) { - const reco = prev_context?.reco && typeof prev_context.reco === "object" ? prev_context.reco : {}; - const base_item = reco.base_item || pickBaseItem({ prev_context, basket_items }); - const context_patch = { reco: { ...reco, base_item } }; - const audit = { base_item, rules_used: [], queries: [] }; + const context_patch = {}; + const audit = { basket_product_ids: [], rules_used: [], recommended_ids: [] }; - if (!base_item?.name) { + // 1. Obtener IDs de productos en el carrito + const basketProductIds = getBasketProductIds(basket_items); + audit.basket_product_ids = basketProductIds; + + if (!basketProductIds.length) { return { - reply: "¿Sobre qué producto querés recomendaciones?", + reply: "Primero agregá algo al carrito y después te sugiero complementos perfectos.", actions: [], context_patch, audit, @@ -122,63 +55,15 @@ export async function handleRecommend({ }; } - // PRIMERO: Inicializar slots y procesar respuesta pendiente ANTES de filtrar reglas - const slots = { ...(reco.slots || {}) }; - let asked_slot = null; - - // Procesar respuesta de slot pendiente PRIMERO - if (reco.awaiting_slot === "alcohol") { - const yn = parseYesNo(text); - if (yn != null) { - slots.alcohol = yn; - context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: null }; - } else { - return { - reply: "¿Tomás alcohol?", - actions: [], - context_patch: { ...context_patch, reco: { ...context_patch.reco, awaiting_slot: "alcohol" } }, - audit, - asked_slot: "alcohol", - candidates: [], - }; - } - } - - // DESPUÉS: Cargar y filtrar reglas CON SLOTS ACTUALIZADOS - const rulesRaw = await getRecoRules({ tenant_id: tenantId }); - const rules = (rulesRaw || []).filter((r) => ruleMatchesBase({ rule: r, base_item, slots })); + // 2. Buscar reglas que matcheen con los productos del carrito + const rules = await getRecoRulesByProductIds({ tenant_id: tenantId, product_ids: basketProductIds }); audit.rules_used = rules.map((r) => ({ id: r.id, rule_key: r.rule_key, priority: r.priority })); - // Verificar si hay slots pendientes por preguntar - const askSlots = collectAskSlots(rules); - if (!context_patch.reco.awaiting_slot) { - const pending = askSlots.find((s) => s?.slot === "alcohol" && slots.alcohol == null); - if (pending) { - asked_slot = "alcohol"; - context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: "alcohol" }; - return { - reply: pending.question || "¿Tomás alcohol?", - actions: [], - context_patch, - audit, - asked_slot, - candidates: [], - }; - } - } - - const queries = collectQueries({ rules, slots }); - audit.queries = queries; - const lists = []; - for (const q of queries.slice(0, 6)) { - const { candidates } = await retrieveCandidates({ tenantId, query: q, limit }); - lists.push(candidates || []); - } - const merged = mergeCandidates({ lists, excludeId: base_item.product_id }); - - if (!merged.length) { + if (!rules.length) { + // Fallback: no hay reglas configuradas para estos productos + const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 3).join(", "); return { - reply: `No encontré recomendaciones para ${base_item.name}. ¿Querés que te sugiera algo distinto?`, + reply: `Por ahora no tengo recomendaciones especiales para ${basketNames}. ¿Te interesa algo más?`, actions: [], context_patch, audit, @@ -187,22 +72,46 @@ export async function handleRecommend({ }; } - const { question, pending } = buildPagedOptions({ candidates: merged, pageSize: Math.min(9, limit) }); - let reply = question; - if (process.env.RECO_WRITER === "1") { - const writer = await llmRecommendWriter({ - base_item, - slots, - candidates: merged.slice(0, limit), - }); - if (writer?.validation?.ok && writer.reply) { - reply = writer.reply; - } - audit.writer = { - ok: Boolean(writer?.validation?.ok), - model: writer?.model || null, + // 3. Obtener IDs de productos recomendados (excluyendo los que ya están en el carrito) + const recommendedIds = collectRecommendedIds(rules, basketProductIds); + audit.recommended_ids = recommendedIds; + + if (!recommendedIds.length) { + return { + reply: "No encontré complementos adicionales para tu pedido. ¿Necesitás algo más?", + actions: [], + context_patch, + audit, + asked_slot: null, + candidates: [], }; } + + // 4. Obtener detalles de los productos recomendados + const recommendedProducts = await getSnapshotItemsByIds({ tenantId, wooProductIds: recommendedIds.slice(0, limit) }); + + if (!recommendedProducts.length) { + return { + reply: "No encontré los productos recomendados disponibles. ¿Querés ver algo más?", + actions: [], + context_patch, + audit, + asked_slot: null, + candidates: [], + }; + } + + // 5. Construir respuesta con opciones + const { question, pending } = buildPagedOptions({ candidates: recommendedProducts, pageSize: Math.min(9, limit) }); + + // Personalizar el mensaje según lo que tiene en el carrito + const basketNames = basket_items.map(i => i.label || i.name).filter(Boolean).slice(0, 2).join(" y "); + const intro = basketNames + ? `Para acompañar ${basketNames}, te recomiendo:` + : "Te recomiendo estos productos:"; + + const reply = `${intro}\n\n${question}`; + context_patch.pending_clarification = pending; context_patch.pending_item = null; @@ -212,6 +121,6 @@ export async function handleRecommend({ context_patch, audit, asked_slot: null, - candidates: merged.slice(0, limit), + candidates: recommendedProducts.slice(0, limit), }; } diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index a215b00..efaaf53 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -172,26 +172,7 @@ 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 { +return { product_id: Number(candidate.woo_product_id), variation_id: null, name: candidate.name, @@ -206,6 +187,528 @@ function askClarificationReply() { return "Dale, ¿qué producto querés exactamente?"; } +// ───────────────────────────────────────────────────────────── +// Funciones para el modelo de carrito acumulativo (pending_items) +// ───────────────────────────────────────────────────────────── + +/** + * Estados posibles de un pending_item: + * - "needs_type": Hay múltiples candidatos, el usuario debe elegir cuál + * - "needs_quantity": Producto resuelto pero falta cantidad + * - "ready": Producto y cantidad resueltos, listo para agregar al carrito + * - "not_found": No se encontró ningún candidato + */ +const PENDING_ITEM_STATUS = { + NEEDS_TYPE: "needs_type", + NEEDS_QUANTITY: "needs_quantity", + READY: "ready", + NOT_FOUND: "not_found", +}; + +/** + * Crea un pending_item a partir de una query y los candidatos del catálogo. + */ +function createPendingItem({ id, query, quantity, unit, candidates }) { + const cands = Array.isArray(candidates) ? candidates.filter(c => c && c.woo_product_id) : []; + + if (cands.length === 0) { + return { + id, + original_query: query, + candidates: [], + resolved: null, + quantity: null, + unit: null, + status: PENDING_ITEM_STATUS.NOT_FOUND, + }; + } + + // Verificar si hay un match único/fuerte + const best = cands[0]; + const second = cands[1]; + const isStrong = cands.length === 1 || + (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2)); + + if (isStrong) { + const resolved = buildPendingItemFromCandidate(best); + const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; + + // Para productos por kg, si no hay unidad explícita y la cantidad es pequeña (<=2), + // consideramos que el usuario no especificó cantidad real + const sellsByWeight = resolved.display_unit !== "unit"; + const hasExplicitUnit = unit != null && unit !== ""; + const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit; + const needsQuantityClarification = sellsByWeight && quantityIsGeneric; + + return { + id, + original_query: query, + candidates: [], // No se necesitan, ya resuelto + resolved, + quantity: (hasQty && !needsQuantityClarification) ? Number(quantity) : null, + unit: unit || null, + status: (hasQty && !needsQuantityClarification) ? PENDING_ITEM_STATUS.READY : PENDING_ITEM_STATUS.NEEDS_QUANTITY, + }; + } + + // Múltiples candidatos, necesita clarificación de tipo + return { + id, + original_query: query, + candidates: cands.slice(0, 12), // Limitar a 12 opciones + resolved: null, + quantity: quantity != null ? Number(quantity) : null, + unit: unit || null, + status: PENDING_ITEM_STATUS.NEEDS_TYPE, + }; +} + +/** + * Extrae todas las menciones de productos del NLU. + * Soporta tanto items[] como product_query individual. + */ +function extractProductMentions(nlu) { + const mentions = []; + + // Si hay items (multi-producto) + if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) { + for (const item of nlu.entities.items) { + if (item.product_query) { + mentions.push({ + query: item.product_query, + quantity: item.quantity, + unit: item.unit, + }); + } + } + } + + // Si hay product_query individual (y no había items) + if (mentions.length === 0 && nlu?.entities?.product_query) { + mentions.push({ + query: nlu.entities.product_query, + quantity: nlu.entities.quantity, + unit: nlu.entities.unit, + }); + } + + return mentions; +} + +/** + * Verifica si el NLU tiene menciones de productos. + */ +function hasProductMentions(nlu) { + return extractProductMentions(nlu).length > 0; +} + +/** + * Construye la respuesta de clarificación de tipo para un item. + */ +function buildTypeClarificationResponse(item, allPendingItems) { + const { question, pending } = buildPagedOptions({ candidates: item.candidates }); + + // Solo preguntar por el item actual, sin mencionar los otros pendientes + const reply = `Para "${item.original_query}", ${question.replace("¿Cuál de estos querés?", "¿cuál de estos querés?")}`; + + return { + reply, + clarification_pending: pending, + clarifying_item_id: item.id, + }; +} + +/** + * Construye la respuesta de clarificación de cantidad para un item. + */ +function buildQuantityClarificationResponse(item, allPendingItems) { + const displayUnit = item.resolved?.display_unit || "kg"; + const askText = unitAskFor(displayUnit); + + // Solo preguntar por el item actual + const reply = `Para ${item.resolved?.name || item.original_query}, ${askText.toLowerCase()}`; + + return { + reply, + clarifying_item_id: item.id, + }; +} + +/** + * Aplica una selección de tipo a un pending_item. + */ +function applyTypeSelection(item, selection, text) { + if (item.status !== PENDING_ITEM_STATUS.NEEDS_TYPE || !item.candidates?.length) { + return item; + } + + // Resolver usando la lógica existente + const resolved = resolvePendingSelection({ + text, + nlu: { entities: { selection } }, + pending: { + candidates: item.candidates, + options: item.candidates.map((c, i) => ({ + idx: i + 1, + type: "product", + woo_product_id: c.woo_product_id, + name: c.name, + })), + }, + }); + + if (resolved.kind === "chosen" && resolved.chosen) { + const resolvedProduct = buildPendingItemFromCandidate(resolved.chosen); + const hasQty = item.quantity != null && Number.isFinite(Number(item.quantity)) && Number(item.quantity) > 0; + + // Para productos por kg, si no hay unidad explícita y la cantidad es pequeña (<=2), + // consideramos que el usuario no especificó cantidad real + const sellsByWeight = resolvedProduct.display_unit !== "unit"; + const hasExplicitUnit = item.unit != null && item.unit !== ""; + const quantityIsGeneric = hasQty && Number(item.quantity) <= 2 && !hasExplicitUnit; + const needsQuantityClarification = sellsByWeight && quantityIsGeneric; + + // #region agent log + fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({location:"turnEngineV3.js:applyTypeSelection",message:"type_selection_qty_check",data:{product_name:resolvedProduct?.name,display_unit:resolvedProduct?.display_unit,item_quantity:item.quantity,item_unit:item.unit,hasQty,sellsByWeight,hasExplicitUnit,quantityIsGeneric,needsQuantityClarification,final_status:needsQuantityClarification?"NEEDS_QUANTITY":"READY"},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H-qty"})}).catch(()=>{}); + // #endregion + + return { + ...item, + candidates: [], + resolved: resolvedProduct, + quantity: (hasQty && !needsQuantityClarification) ? Number(item.quantity) : null, + status: (hasQty && !needsQuantityClarification) ? PENDING_ITEM_STATUS.READY : PENDING_ITEM_STATUS.NEEDS_QUANTITY, + }; + } + + return item; +} + +/** + * Aplica una cantidad a un pending_item. + */ +function applyQuantity(item, quantity, unit) { + if (item.status !== PENDING_ITEM_STATUS.NEEDS_QUANTITY || !item.resolved) { + return item; + } + + const qty = resolveQuantity({ + quantity, + unit, + displayUnit: item.resolved.display_unit, + }); + + if (qty?.quantity) { + return { + ...item, + quantity: qty.quantity, + unit: qty.unit, + display_quantity: qty.display_quantity, + display_unit: qty.display_unit, + status: PENDING_ITEM_STATUS.READY, + }; + } + + return item; +} + +/** + * Formatea un item listo para mostrar en la confirmación. + */ +function formatReadyItem(item) { + const name = item.resolved?.name || item.original_query; + const dq = item.display_quantity || item.quantity; + const du = item.display_unit || item.unit; + + if (du === "kg") return `${dq}kg de ${name}`; + if (du === "unit" || du === "unidad") return `${dq} ${name}`; + if (du === "g") return `${dq}g de ${name}`; + return `${dq} de ${name}`; +} + +// ───────────────────────────────────────────────────────────── +// Funciones principales del flujo acumulativo +// ───────────────────────────────────────────────────────────── + +/** + * Inicializa pending_items a partir de las menciones de productos del NLU. + * Busca cada producto en el catálogo y determina su estado inicial. + */ +async function initializePendingItems({ + tenantId, + nlu, + prev_state, + prev_context, + text, + audit, +}) { + const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; + const mentions = extractProductMentions(nlu); + const context_patch = {}; + const actions = []; + + if (mentions.length === 0) { + return null; // No hay productos que procesar + } + + const pending_items = []; + const timestamp = Date.now(); + + for (let i = 0; i < mentions.length; i++) { + const mention = mentions[i]; + const { candidates, audit: catAudit } = await retrieveCandidates({ + tenantId, + query: mention.query, + limit: 12, + }); + + audit.catalog_init = audit.catalog_init || []; + audit.catalog_init.push({ query: mention.query, count: candidates?.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({location:"turnEngineV3.js:initializePendingItems",message:"creating_pending_item",data:{query:mention.query,quantity:mention.quantity,unit:mention.unit,candidates_count:candidates?.length||0},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H-init"})}).catch(()=>{}); + // #endregion + + const item = createPendingItem({ + id: `pi_${timestamp}_${i}`, + query: mention.query, + quantity: mention.quantity, + unit: mention.unit, + candidates: candidates || [], + }); + + // Solo agregar si se encontró algo + if (item.status !== PENDING_ITEM_STATUS.NOT_FOUND) { + pending_items.push(item); + } + } + + if (pending_items.length === 0) { + // Ningún producto encontrado + const { next_state, validation: v } = safeNextState(prev_state, prev, {}); + return { + plan: { + reply: "No encontré ninguno de esos productos en el catálogo. ¿Podés ser más específico?", + next_state, + intent: nlu?.intent || "browse", + missing_fields: ["product"], + order_action: "none", + basket_resolved: { items: [] }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Guardar pending_items en contexto + context_patch.pending_items = pending_items; + + // Continuar con el procesamiento de clarificación + return await processPendingItemsClarification({ + tenantId, + nlu, + prev_state, + prev_context: { ...prev, ...context_patch }, + text, + audit, + isInitializing: true, + }); +} + +/** + * Procesa la clarificación de pending_items uno por uno. + * Aplica selecciones o cantidades del NLU, luego busca el siguiente item a clarificar. + */ +async function processPendingItemsClarification({ + tenantId, + nlu, + prev_state, + prev_context, + text, + audit, + isInitializing = false, +}) { + const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; + let pending_items = Array.isArray(prev?.pending_items) ? [...prev.pending_items] : []; + const context_patch = {}; + const actions = []; + + // Si no estamos inicializando, aplicar la respuesta del usuario + if (!isInitializing && pending_items.length > 0) { + const clarifyingId = prev?.clarifying_item_id; + const clarifyingItem = clarifyingId ? pending_items.find(i => i.id === clarifyingId) : null; + + // Determinar si el usuario está respondiendo a una selección de tipo o dando cantidad + const isRespondingToTypeSelection = clarifyingItem?.status === PENDING_ITEM_STATUS.NEEDS_TYPE; + const hasSelection = nlu?.entities?.selection || parseIndexSelection(text); + + // Si el usuario dio una selección de tipo + if (hasSelection && isRespondingToTypeSelection) { + const itemToUpdate = clarifyingItem || pending_items.find(i => i.status === PENDING_ITEM_STATUS.NEEDS_TYPE); + + if (itemToUpdate) { + const updatedItem = applyTypeSelection(itemToUpdate, nlu?.entities?.selection, text); + const idx = pending_items.findIndex(i => i.id === itemToUpdate.id); + if (idx >= 0) pending_items[idx] = updatedItem; + } + // NO aplicar quantity si estamos respondiendo a una selección de tipo + // El número "2" es la selección, no la cantidad + } + // Si el usuario dio una cantidad (y NO estamos respondiendo a selección de tipo) + else if (nlu?.entities?.quantity != null) { + const itemToUpdate = clarifyingId + ? pending_items.find(i => i.id === clarifyingId) + : pending_items.find(i => i.status === PENDING_ITEM_STATUS.NEEDS_QUANTITY); + + if (itemToUpdate && itemToUpdate.status === PENDING_ITEM_STATUS.NEEDS_QUANTITY) { + const updatedItem = applyQuantity(itemToUpdate, nlu.entities.quantity, nlu.entities.unit); + const idx = pending_items.findIndex(i => i.id === itemToUpdate.id); + if (idx >= 0) pending_items[idx] = updatedItem; + } + } + } + + // Buscar siguiente item que necesita clarificación de tipo + const needsType = pending_items.find(i => i.status === PENDING_ITEM_STATUS.NEEDS_TYPE); + if (needsType) { + const { reply, clarification_pending, clarifying_item_id } = buildTypeClarificationResponse(needsType, pending_items); + + context_patch.pending_items = pending_items; + context_patch.clarifying_item_id = clarifying_item_id; + context_patch.pending_clarification = clarification_pending; // Para compatibilidad con NLU + + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true }); + actions.push({ type: "show_options", payload: { count: clarification_pending?.options?.length || 0 } }); + + return { + plan: { + reply, + next_state, + intent: "add_to_cart", + missing_fields: ["product_type"], + order_action: "none", + basket_resolved: { items: [] }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Buscar siguiente item que necesita cantidad + const needsQty = pending_items.find(i => i.status === PENDING_ITEM_STATUS.NEEDS_QUANTITY); + if (needsQty) { + const { reply, clarifying_item_id } = buildQuantityClarificationResponse(needsQty, pending_items); + + context_patch.pending_items = pending_items; + context_patch.clarifying_item_id = clarifying_item_id; + context_patch.pending_clarification = null; // Ya no hay opciones que mostrar + + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {}); + + return { + plan: { + reply, + next_state, + intent: "add_to_cart", + missing_fields: ["quantity"], + order_action: "none", + basket_resolved: { items: [] }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Todos los items están listos (status = "ready") + const readyItems = pending_items.filter(i => i.status === PENDING_ITEM_STATUS.READY); + if (readyItems.length > 0) { + return await addAllToCart({ + readyItems, + prev_state, + prev_context: { ...prev, pending_items }, + audit, + }); + } + + // Fallback: no hay items válidos + const { next_state, validation: v } = safeNextState(prev_state, prev, {}); + return { + plan: { + reply: "No pude procesar los productos. ¿Podés intentar de nuevo?", + next_state, + intent: "other", + missing_fields: [], + order_action: "none", + basket_resolved: { items: [] }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; +} + +/** + * Agrega todos los items listos al carrito. + */ +async function addAllToCart({ + readyItems, + prev_state, + prev_context, + audit, +}) { + const prev = prev_context && typeof prev_context === "object" ? prev_context : {}; + const context_patch = {}; + const actions = []; + + // Obtener carrito existente + const existingItems = Array.isArray(prev?.order_basket?.items) ? [...prev.order_basket.items] : []; + const newCartItems = []; + const addedLabels = []; + + for (const item of readyItems) { + if (!item.resolved || !item.quantity) continue; + + const cartItem = { + product_id: item.resolved.product_id, + variation_id: item.resolved.variation_id, + quantity: item.quantity, + unit: item.unit, + label: item.resolved.name, + }; + + newCartItems.push(cartItem); + actions.push({ type: "add_to_cart", payload: cartItem }); + addedLabels.push(formatReadyItem(item)); + } + + // Actualizar carrito + context_patch.order_basket = { items: [...existingItems, ...newCartItems] }; + + // Limpiar pending_items + context_patch.pending_items = null; + context_patch.clarifying_item_id = null; + context_patch.pending_clarification = null; + context_patch.pending_item = null; // Limpiar también el modelo legacy + context_patch.pending_quantity = null; + + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true }); + + const reply = addedLabels.length === 1 + ? `Perfecto, anoto ${addedLabels[0]}. ¿Algo más?` + : `Listo, anoto:\n${addedLabels.map(l => `- ${l}`).join("\n")}\n\n¿Algo más?`; + + return { + plan: { + reply, + next_state, + intent: "add_to_cart", + missing_fields: [], + order_action: "none", + basket_resolved: { items: newCartItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; +} + +// ───────────────────────────────────────────────────────────── +// Fin flujo acumulativo principal +// ───────────────────────────────────────────────────────────── + function shortSummary(history) { if (!Array.isArray(history)) return ""; return history @@ -399,6 +902,10 @@ export async function runTurnV3({ const context_patch = {}; const audit = {}; + // #region agent log + fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({location:"turnEngineV3.js:898",message:"runTurnV3_entry",data:{prev_state,text_preview:String(text||"").slice(0,50),has_prev_context:!!prev_context,prev_order_basket_exists:!!prev?.order_basket,prev_basket_items_count:prev?.order_basket?.items?.length||0,prev_basket_labels:(prev?.order_basket?.items||[]).map(i=>i.label)},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H3-H4"})}).catch(()=>{}); + // #endregion + // Observabilidad (NO se envía al LLM) audit.trace = { tenantId: tenantId || null, @@ -420,63 +927,62 @@ 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 }); +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 +// ───────────────────────────────────────────────────────────── + // NUEVO FLUJO: Carrito acumulativo con pending_items + // ───────────────────────────────────────────────────────────── + + // 0a) Si hay pending_items existentes, continuar clarificación + if (Array.isArray(prev?.pending_items) && prev.pending_items.length > 0) { + const hasPendingToProcess = prev.pending_items.some( + i => i.status === PENDING_ITEM_STATUS.NEEDS_TYPE || i.status === PENDING_ITEM_STATUS.NEEDS_QUANTITY + ); + if (hasPendingToProcess) { + const result = await processPendingItemsClarification({ + tenantId, + nlu, + prev_state, + prev_context: prev, + text, + audit, + isInitializing: false, + }); + if (result) return result; + } + } + + // 0b) Si el NLU detecta productos y no hay pending_items, inicializar nuevo flujo + const nluIntent = nlu?.intent || "other"; + const isProductIntent = ["add_to_cart", "browse", "price_query"].includes(nluIntent); + const hasProducts = hasProductMentions(nlu); + const noPendingItems = !Array.isArray(prev?.pending_items) || prev.pending_items.length === 0; + const noLegacyPending = !prev?.pending_clarification?.candidates?.length && !prev?.pending_item?.product_id; + + if (isProductIntent && hasProducts && noPendingItems && noLegacyPending) { + const result = await initializePendingItems({ + tenantId, + nlu, + prev_state, + prev_context: prev, + text, + audit, + }); + if (result) return result; + } + + // ───────────────────────────────────────────────────────────── + // FLUJO LEGACY (mantener para compatibilidad) + // ───────────────────────────────────────────────────────────── - // 0) Procesar multi-items si hay varios productos en un mensaje + // 0) Procesar multi-items si hay varios productos en un mensaje (LEGACY) // 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 + !prev?.pending_item?.product_id && + !Array.isArray(prev?.pending_items) // No usar si ya hay pending_items ) { const multiResult = await processMultiItems({ tenantId, @@ -491,30 +997,10 @@ export async function runTurnV3({ // Si multiResult es null, ningún item fue encontrado, seguir con flujo normal } - // 1) Resolver pending_clarification primero + // 1) Resolver pending_clarification primero (LEGACY) 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") { +if (resolved.kind === "more") { const nextPending = resolved.pending || prev.pending_clarification; const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question; context_patch.pending_clarification = nextPending; @@ -641,27 +1127,7 @@ export async function runTurnV3({ 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) { +if (qty?.quantity) { const item = { product_id: Number(pendingItem.product_id), variation_id: pendingItem.variation_id ?? null, @@ -743,26 +1209,7 @@ export async function runTurnV3({ : 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 : []; @@ -815,7 +1262,77 @@ export async function runTurnV3({ }; } - if (intent === "checkout") { + // Handler para view_cart: mostrar contenido del carrito y pending_items + if (intent === "view_cart") { + const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; + const pendingItems = Array.isArray(prev?.pending_items) ? prev.pending_items : []; + + const lines = []; + + // Items ya en el carrito + if (basketItems.length > 0) { + lines.push("Tenés anotado:"); + for (const item of basketItems) { + const qty = item.quantity || 1; + const unit = item.unit || "unit"; + const label = item.label || "Producto"; + if (unit === "kg" || unit === "g") { + const displayQty = unit === "g" ? `${qty}g` : `${qty / 1000}kg`; + lines.push(`- ${displayQty} de ${label}`); + } else { + lines.push(`- ${qty} ${label}`); + } + } + } + + // Items pendientes de clarificar + const pendingToClarify = pendingItems.filter( + i => i.status === PENDING_ITEM_STATUS.NEEDS_TYPE || i.status === PENDING_ITEM_STATUS.NEEDS_QUANTITY + ); + if (pendingToClarify.length > 0) { + if (lines.length > 0) lines.push(""); + lines.push("Pendiente de confirmar:"); + for (const item of pendingToClarify) { + const name = item.resolved?.name || item.original_query; + const status = item.status === PENDING_ITEM_STATUS.NEEDS_TYPE ? "(elegir tipo)" : "(elegir cantidad)"; + lines.push(`- ${name} ${status}`); + } + } + + const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {}); + + if (lines.length === 0) { + return { + plan: { + reply: "Todavía no tenés nada anotado. ¿Qué te gustaría pedir?", + next_state, + intent: "view_cart", + missing_fields: [], + order_action: "none", + basket_resolved: { items: [] }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + lines.push(""); + lines.push("¿Algo más?"); + + return { + plan: { + reply: lines.join("\n"), + next_state, + intent: "view_cart", + missing_fields: [], + order_action: "none", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Handler para confirm_order: usuario quiere cerrar el pedido + if (intent === "confirm_order" || intent === "checkout") { const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; if (!basketItems.length) { const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {}); @@ -823,7 +1340,7 @@ export async function runTurnV3({ plan: { reply: "Para avanzar necesito al menos un producto. ¿Qué querés pedir?", next_state, - intent: "checkout", + intent, missing_fields: ["basket_items"], order_action: "none", basket_resolved: { items: [] }, @@ -831,31 +1348,200 @@ export async function runTurnV3({ decision: { actions, context_patch, audit: { ...audit, fsm: v } }, }; } - if (!hasAddress(prev)) { - actions.push({ type: "ask_address", payload: {} }); - const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true }); + + // Preguntar método de pago + context_patch.checkout_step = "payment_method"; + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {}); + + return { + plan: { + reply: "Perfecto. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", + next_state, + intent, + missing_fields: ["payment_method"], + order_action: "none", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Handler para select_payment: usuario elige método de pago + if (intent === "select_payment" || (prev?.checkout_step === "payment_method" && (nlu?.entities?.payment_method || nlu?.entities?.selection))) { + const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; + + // Determinar método de pago + let paymentMethod = nlu?.entities?.payment_method; + if (!paymentMethod && nlu?.entities?.selection) { + const sel = nlu.entities.selection.value; + if (sel === "1" || /efectivo|cash/i.test(sel)) paymentMethod = "cash"; + else if (sel === "2" || /link|tarjeta|transfer/i.test(sel)) paymentMethod = "link"; + } + // Fallback: detectar por texto + if (!paymentMethod) { + const t = String(text || "").toLowerCase(); + if (/efectivo|cash|plata/i.test(t)) paymentMethod = "cash"; + else if (/link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) paymentMethod = "link"; + } + + if (!paymentMethod) { + const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {}); return { plan: { - reply: "Perfecto. ¿Me pasás la dirección de entrega?", + reply: "No entendí. ¿Preferís pagar en efectivo o con link de pago (tarjeta)?", next_state, - intent: "checkout", - missing_fields: ["address"], - order_action: "checkout", + intent: "select_payment", + missing_fields: ["payment_method"], + order_action: "none", basket_resolved: { items: basketItems }, }, decision: { actions, context_patch, audit: { ...audit, fsm: v } }, }; } - actions.push({ type: "create_order", payload: {} }); - actions.push({ type: "send_payment_link", payload: {} }); - const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, { requested_checkout: true }); + + // Guardar método de pago y preguntar delivery/retiro + context_patch.payment_method = paymentMethod; + context_patch.checkout_step = "shipping_method"; + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {}); + + const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago"; return { plan: { - reply: "Genial, ya genero el link de pago y te lo paso.", + reply: `Anotado, pagás en ${paymentLabel}.\n\n¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal`, next_state, - intent: "checkout", + intent: "select_payment", + missing_fields: ["shipping_method"], + order_action: "none", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Handler para select_shipping: usuario elige delivery o retiro + if (intent === "select_shipping" || (prev?.checkout_step === "shipping_method" && (nlu?.entities?.shipping_method || nlu?.entities?.selection))) { + const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; + + // Determinar método de envío + let shippingMethod = nlu?.entities?.shipping_method; + if (!shippingMethod && nlu?.entities?.selection) { + const sel = nlu.entities.selection.value; + if (sel === "1" || /delivery|envío|envio|traigan/i.test(sel)) shippingMethod = "delivery"; + else if (sel === "2" || /retiro|retira|buscar|sucursal/i.test(sel)) shippingMethod = "pickup"; + } + // Fallback: detectar por texto + if (!shippingMethod) { + const t = String(text || "").toLowerCase(); + if (/delivery|envío|envio|traigan|llev/i.test(t)) shippingMethod = "delivery"; + else if (/retiro|retira|buscar|sucursal|paso/i.test(t)) shippingMethod = "pickup"; + } + + if (!shippingMethod) { + const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {}); + return { + plan: { + reply: "No entendí. ¿Es para delivery o retiro en sucursal?", + next_state, + intent: "select_shipping", + missing_fields: ["shipping_method"], + order_action: "none", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + context_patch.shipping_method = shippingMethod; + context_patch.checkout_step = null; + + if (shippingMethod === "delivery") { + // Pedir dirección + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { requested_address: true }); + return { + plan: { + reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?", + next_state, + intent: "select_shipping", + missing_fields: ["address"], + order_action: "none", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Retiro en sucursal: crear orden directamente + actions.push({ type: "create_order", payload: { shipping: "pickup" } }); + if (prev?.payment_method === "link") { + actions.push({ type: "send_payment_link", payload: {} }); + } + + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch, woo_order_pending: true }, {}); + + const paymentMsg = prev?.payment_method === "link" + ? "Te paso el link de pago en un momento." + : "Pagás en efectivo cuando retires."; + + return { + plan: { + reply: `Listo, retiro en sucursal. ${paymentMsg}\n\n¡Gracias por tu pedido!`, + next_state, + intent: "select_shipping", missing_fields: [], - order_action: "checkout", + order_action: "create_order", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + // Handler para provide_address: usuario da dirección de entrega + if (intent === "provide_address" || (prev?.shipping_method === "delivery" && !hasAddress(prev) && nlu?.entities?.address)) { + const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; + + // Extraer dirección del NLU o usar el texto completo + let address = nlu?.entities?.address; + if (!address && text && text.length > 5) { + // Si no hay entities.address pero el texto parece una dirección + address = text.trim(); + } + + if (!address) { + const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {}); + return { + plan: { + reply: "Necesito la dirección de entrega. ¿Me la pasás?", + next_state, + intent: "provide_address", + missing_fields: ["address"], + order_action: "none", + basket_resolved: { items: basketItems }, + }, + decision: { actions, context_patch, audit: { ...audit, fsm: v } }, + }; + } + + context_patch.delivery_address = { text: address }; + + // Crear orden + actions.push({ type: "create_order", payload: { shipping: "delivery", address } }); + if (prev?.payment_method === "link") { + actions.push({ type: "send_payment_link", payload: {} }); + } + + const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch, woo_order_pending: true }, {}); + + const paymentMsg = prev?.payment_method === "link" + ? "Te paso el link de pago en un momento." + : "Pagás en efectivo cuando te llegue."; + + return { + plan: { + reply: `Perfecto, te lo enviamos a: ${address}\n\n${paymentMsg}\n\n¡Gracias por tu pedido!`, + next_state, + intent: "provide_address", + missing_fields: [], + order_action: "create_order", basket_resolved: { items: basketItems }, }, decision: { actions, context_patch, audit: { ...audit, fsm: v } }, @@ -962,18 +1648,35 @@ export async function runTurnV3({ unit: qty.unit, label: pendingItem.name, }; - const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; - context_patch.order_basket = { items: [...prevItems, item] }; - actions.push({ type: "add_to_cart", payload: item }); + const prevItems = Array.isArray(prev?.order_basket?.items) ? [...prev.order_basket.items] : []; + + // Buscar si ya existe el mismo producto en el carrito + const existingIdx = prevItems.findIndex(i => i.product_id === item.product_id); + let actionType = "add_to_cart"; + if (existingIdx >= 0) { + // Actualizar cantidad del item existente + prevItems[existingIdx] = { ...prevItems[existingIdx], quantity: item.quantity, unit: item.unit }; + actionType = "update_cart_item"; + } else { + // Agregar nuevo item + prevItems.push(item); + } + + // #region agent log + fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({location:"turnEngineV3.js:1645",message:"add_to_cart_basket_state",data:{prev_basket_exists:!!prev?.order_basket,prev_basket_items_count:prev?.order_basket?.items?.length||0,prev_items_labels:prevItems.map(i=>i.label),new_item_label:item.label,new_item_qty:item.quantity,new_item_unit:item.unit,was_update:existingIdx>=0},timestamp:Date.now(),sessionId:"debug-session",hypothesisId:"H1-H2"})}).catch(()=>{}); + // #endregion + context_patch.order_basket = { items: prevItems }; + actions.push({ type: actionType, payload: item }); 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` : qty.display_unit === "unit" ? `${qty.display_quantity}u` : `${qty.display_quantity}g`; + const verb = existingIdx >= 0 ? "cambio a" : "anoto"; return { plan: { - reply: `Perfecto, anoto ${display} de ${pendingItem.name}. ¿Algo más?`, + reply: `Perfecto, ${verb} ${display} de ${pendingItem.name}. ¿Algo más?`, next_state, intent: "add_to_cart", missing_fields: [], diff --git a/src/modules/shared/wooSnapshot.js b/src/modules/shared/wooSnapshot.js index d421fff..73d8ebc 100644 --- a/src/modules/shared/wooSnapshot.js +++ b/src/modules/shared/wooSnapshot.js @@ -146,36 +146,7 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) { const query = String(q || "").trim(); if (!query) return { items: [], source: "snapshot" }; const like = `%${query}%`; - // #region agent log - const totalSnapshot = await pool.query( - "select count(*)::int as cnt from woo_products_snapshot where tenant_id=$1", - [tenantId] - ); - const totalSellable = await pool.query( - "select count(*)::int as cnt from sellable_items where tenant_id=$1", - [tenantId] - ); - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H8", - location: "wooSnapshot.js:152", - message: "snapshot_counts", - data: { - tenantId: tenantId || null, - total_snapshot: totalSnapshot?.rows?.[0]?.cnt ?? null, - total_sellable: totalSellable?.rows?.[0]?.cnt ?? null, - query, - limit: lim, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - const sql = ` +const sql = ` select * from sellable_items where tenant_id=$1 @@ -184,26 +155,7 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) { limit $3 `; const { rows } = await pool.query(sql, [tenantId, like, lim]); - // #region agent log - fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionId: "debug-session", - runId: "pre-fix", - hypothesisId: "H8", - location: "wooSnapshot.js:168", - message: "snapshot_search_result", - data: { - query, - found: rows.length, - sample_names: rows.slice(0, 3).map((r) => r?.name).filter(Boolean), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - return { items: rows.map(snapshotRowToItem), source: "snapshot" }; +return { items: rows.map(snapshotRowToItem), source: "snapshot" }; } export async function getSnapshotPriceByWooId({ tenantId, wooId }) { @@ -219,6 +171,27 @@ export async function getSnapshotPriceByWooId({ tenantId, wooId }) { return price == null ? null : Number(price); } +/** + * Obtiene productos de sellable_items por sus woo_product_ids. + * Usado para incluir productos encontrados vía aliases. + */ +export async function getSnapshotItemsByIds({ tenantId, wooProductIds }) { + if (!Array.isArray(wooProductIds) || wooProductIds.length === 0) { + return { items: [], source: "snapshot_by_id" }; + } + const ids = wooProductIds.map(id => Number(id)).filter(id => id > 0); + if (ids.length === 0) return { items: [], source: "snapshot_by_id" }; + + const placeholders = ids.map((_, i) => `$${i + 2}`).join(","); + const sql = ` + select * + from sellable_items + where tenant_id=$1 and woo_id in (${placeholders}) + `; + const { rows } = await pool.query(sql, [tenantId, ...ids]); + return { items: rows.map(snapshotRowToItem), source: "snapshot_by_id" }; +} + export async function upsertSnapshotItems({ tenantId, items, runId = null }) { const rows = Array.isArray(items) ? items : []; for (const item of rows) {