ux improved

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

153
.cursor/debug.log Normal file
View File

@@ -0,0 +1,153 @@
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629587289}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":29},"timestamp":1768629587320}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"IDLE","isStale":false,"state_updated_at":"2026-01-17T05:59:47.320Z","has_context":true},"timestamp":1768629587326}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":0,"heights":16,"applied":0,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629587333}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":0,"heights":0,"applied":0,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629587336}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":0,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629587336}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":29,"state":"IDLE","memory_len":31,"pending_clarification":false,"pending_item":false,"last_shown_options":0},"timestamp":1768629587339}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":"kg","selection":"null","needs_catalog":true},"timestamp":1768629590578}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":false,"nlu_valid":true,"raw_len":303},"timestamp":1768629590580}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"asado","limit":12},"timestamp":1768629590590}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"asado","found":12,"sample_names":["ASADO VENTANA","Tapa de Asado Wagyu","Tapa De Asado (copia)"]},"timestamp":1768629590595}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"asado","aliases_count":4,"snapshot_count":12,"snapshot_source":"snapshot"},"timestamp":1768629590596}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"browse","next_state":"BROWSING","missing_fields":1,"actions_count":1},"timestamp":1768629590624}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":0,"applied":0,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629590646}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629590653}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":2,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629590653}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629595476}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":1},"timestamp":1768629595481}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"BROWSING","isStale":false,"state_updated_at":"2026-01-17T05:59:50.622Z","has_context":true},"timestamp":1768629595485}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629595487}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":2,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629595487}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":2,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629595493}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":1,"state":"BROWSING","memory_len":162,"pending_clarification":true,"pending_item":false,"last_shown_options":10},"timestamp":1768629595493}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H11","location":"openai.js:129","message":"selection_inferred","data":{"inferred":true,"pending_item":false,"has_shown_options":true,"text":"6"},"timestamp":1768629598519}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":null,"selection":"set","needs_catalog":false},"timestamp":1768629598519}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":false,"has_pending_clarification":true,"has_pending_item":false,"nlu_valid":true,"raw_len":359},"timestamp":1768629598519}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:239","message":"pending_clarification_resolved","data":{"kind":"chosen","selection_type":"index","selection_value":"6","text_len":1},"timestamp":1768629598520}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H14","location":"turnEngineV3.js:171","message":"pending_item_display_unit","data":{"name":"Asado Premium","categories":["Carnes > Vacuna"],"display_unit":"kg"},"timestamp":1768629598521}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"AWAITING_QUANTITY","missing_fields":1,"actions_count":0},"timestamp":1768629598521}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":2,"applied":2,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629598551}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629598553}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":4,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629598554}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629603948}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":6},"timestamp":1768629603951}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629603959}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":4,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629603960}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":4,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629603962}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"AWAITING_QUANTITY","isStale":false,"state_updated_at":"2026-01-17T05:59:58.532Z","has_context":true},"timestamp":1768629603963}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":6,"state":"AWAITING_QUANTITY","memory_len":200,"pending_clarification":false,"pending_item":true,"last_shown_options":0},"timestamp":1768629603968}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":"kg","selection":"null","needs_catalog":true},"timestamp":1768629607572}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:332","message":"pending_item_quantity","data":{"quantity_in":1,"unit_in":"kg","qty_resolved":1000,"text":"1 dije"},"timestamp":1768629607573}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":true,"nlu_valid":true,"raw_len":317},"timestamp":1768629607573}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"CART_ACTIVE","missing_fields":0,"actions_count":1},"timestamp":1768629607573}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":4,"applied":4,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629607595}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629607604}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":6,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629607604}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629612932}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":18},"timestamp":1768629612934}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629612940}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":6,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629612940}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":6,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629612946}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:00:07.593Z","has_context":true},"timestamp":1768629612947}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":18,"state":"CART_ACTIVE","memory_len":117,"pending_clarification":false,"pending_item":false,"last_shown_options":0},"timestamp":1768629612951}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"recommend","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629616398}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"recommend","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":false,"nlu_valid":true,"raw_len":305},"timestamp":1768629616398}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"recommend","next_state":"CART_ACTIVE","missing_fields":1,"actions_count":0},"timestamp":1768629616403}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":6,"applied":6,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629616430}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629616438}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":8,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629616438}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629618573}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":2},"timestamp":1768629618575}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:00:16.415Z","has_context":true},"timestamp":1768629618578}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629618585}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":2,"state":"CART_ACTIVE","memory_len":111,"pending_clarification":false,"pending_item":false,"last_shown_options":0},"timestamp":1768629618584}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":8,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629618588}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":8,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":967,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629618588}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"recommend","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629622528}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"recommend","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":false,"nlu_valid":true,"raw_len":305},"timestamp":1768629622528}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"provoleta","limit":9},"timestamp":1768629622538}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"provoleta","found":7,"sample_names":["Quesos Provoletas de Vaca Santa Rosa","Quesos Provoletas de Vaca Formagge","Queso Provoleta de Cabra"]},"timestamp":1768629622543}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"provoleta","aliases_count":0,"snapshot_count":7,"snapshot_source":"snapshot"},"timestamp":1768629622543}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"chimichurri","limit":9},"timestamp":1768629622563}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"chimichurri","found":6,"sample_names":["Finca la victoria (chimichurri, porotos, morrones, salsa criolla, berenjenas) - berenjena","Finca la victoria (chimichurri, porotos, morrones, salsa criolla, berenjenas) - morrones","Finca la victoria (chimichurri, porotos, morrones, salsa criolla, berenjenas) - porotos"]},"timestamp":1768629622566}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"chimichurri","aliases_count":0,"snapshot_count":6,"snapshot_source":"snapshot"},"timestamp":1768629622567}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"ensalada","limit":9},"timestamp":1768629622582}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"ensalada","found":0,"sample_names":[]},"timestamp":1768629622586}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"ensalada","aliases_count":0,"snapshot_count":0,"snapshot_source":"snapshot"},"timestamp":1768629622586}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"pan","limit":9},"timestamp":1768629622591}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"pan","found":9,"sample_names":["Panceta Bajo sodio Curada FETEADA","Panceta Bajo sodio Curada (copia)","PAN CASERO"]},"timestamp":1768629622595}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"pan","aliases_count":1,"snapshot_count":9,"snapshot_source":"snapshot"},"timestamp":1768629622595}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"vino tinto","limit":9},"timestamp":1768629622616}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"vino tinto","found":0,"sample_names":[]},"timestamp":1768629622619}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"vino tinto","aliases_count":0,"snapshot_count":0,"snapshot_source":"snapshot"},"timestamp":1768629622619}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:152","message":"snapshot_counts","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","total_snapshot":780,"total_sellable":769,"query":"malbec","limit":9},"timestamp":1768629622624}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H8","location":"wooSnapshot.js:168","message":"snapshot_search_result","data":{"query":"malbec","found":9,"sample_names":["VINO Bizzotto Reserva Malbec","VINO Peñon de Agrelo Malbec","VINO CASTORE MALBEC"]},"timestamp":1768629622628}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H9","location":"catalogRetrieval.js:158","message":"catalog_sources","data":{"query":"malbec","aliases_count":0,"snapshot_count":9,"snapshot_source":"snapshot"},"timestamp":1768629622629}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"recommend","next_state":"CART_ACTIVE","missing_fields":1,"actions_count":1},"timestamp":1768629637044}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":8,"applied":8,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629637120}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":10,"applied":10,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629637121}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":10,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":998,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629637122}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629663933}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":36},"timestamp":1768629663936}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":10,"applied":10,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629663943}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":10,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":998,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629663944}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:00:37.072Z","has_context":true},"timestamp":1768629663948}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":36,"state":"CART_ACTIVE","memory_len":213,"pending_clarification":true,"pending_item":false,"last_shown_options":10},"timestamp":1768629663954}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":10,"heights":10,"applied":10,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629663961}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H11","location":"openai.js:129","message":"selection_inferred","data":{"inferred":false,"pending_item":false,"has_shown_options":true,"text":"ok, agregame chimich"},"timestamp":1768629668306}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"add_to_cart","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629668306}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"add_to_cart","needsCatalog":true,"has_pending_clarification":true,"has_pending_item":false,"nlu_valid":true,"raw_len":445},"timestamp":1768629668306}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:239","message":"pending_clarification_resolved","data":{"kind":"ask","selection_type":null,"selection_value":null,"text_len":36},"timestamp":1768629668307}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"browse","next_state":"CART_ACTIVE","missing_fields":1,"actions_count":1},"timestamp":1768629668307}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":10,"applied":10,"scroll_height":1408,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629668385}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629668390}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629668390}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629676646}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":5},"timestamp":1768629676649}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"CART_ACTIVE","isStale":false,"state_updated_at":"2026-01-17T06:01:08.342Z","has_context":true},"timestamp":1768629676652}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629676658}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629676659}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":5,"state":"CART_ACTIVE","memory_len":305,"pending_clarification":true,"pending_item":false,"last_shown_options":10},"timestamp":1768629676661}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1390,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629676664}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H11","location":"openai.js:129","message":"selection_inferred","data":{"inferred":true,"pending_item":false,"has_shown_options":true,"text":"1 y 7"},"timestamp":1768629681604}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"add_to_cart","needsCatalog":true,"has_pending_clarification":true,"has_pending_item":false,"nlu_valid":true,"raw_len":370},"timestamp":1768629681605}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"add_to_cart","unit":null,"selection":"set","needs_catalog":true},"timestamp":1768629681604}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:239","message":"pending_clarification_resolved","data":{"kind":"chosen","selection_type":"index","selection_value":"1","text_len":5},"timestamp":1768629681605}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H14","location":"turnEngineV3.js:171","message":"pending_item_display_unit","data":{"name":"Chimichurri","categories":["Proveeduría > Sal pimienta y especias"],"display_unit":"unit"},"timestamp":1768629681605}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"ERROR_RECOVERY","missing_fields":1,"actions_count":0},"timestamp":1768629681606}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":12,"applied":12,"scroll_height":1639,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629681638}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":1618,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629681641}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629681642}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H1","location":"evolution.js:9","message":"parsed_webhook","data":{"ok":true,"reason":null,"has_text":true,"source":"sim"},"timestamp":1768629697326}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H2","location":"pipeline.js:128","message":"processMessage_enter","data":{"tenantId":"eb71b9a7-9ccf-430e-9b25-951a0c589c0f","provider":"evolution","chat_id":"5432233230322@s.whatsapp.net","text_len":39},"timestamp":1768629697329}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H3","location":"pipeline.js:150","message":"conversation_state_loaded","data":{"prev_state":"ERROR_RECOVERY","isStale":false,"state_updated_at":"2026-01-17T06:01:21.616Z","has_context":true},"timestamp":1768629697340}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":1618,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629697341}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":1618,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629697342}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1439,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629697342}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H6","location":"turnEngineV3.js:231","message":"nlu_input_built","data":{"text_len":39,"state":"ERROR_RECOVERY","memory_len":247,"pending_clarification":false,"pending_item":true,"last_shown_options":0},"timestamp":1768629697347}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H10","location":"openai.js:196","message":"nlu_normalized_first","data":{"intent":"other","unit":null,"selection":"null","needs_catalog":true},"timestamp":1768629702666}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H5","location":"turnEngineV3.js:235","message":"nlu_result","data":{"intent":"other","needsCatalog":true,"has_pending_clarification":false,"has_pending_item":true,"nlu_valid":true,"raw_len":513},"timestamp":1768629702666}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H12","location":"turnEngineV3.js:332","message":"pending_item_quantity","data":{"quantity_in":0,"unit_in":null,"qty_resolved":null,"text":"chimichurri una unid"},"timestamp":1768629702666}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H4","location":"pipeline.js:198","message":"turn_v3_result","data":{"intent":"add_to_cart","next_state":"AWAITING_QUANTITY","missing_fields":1,"actions_count":0},"timestamp":1768629702667}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":14,"applied":14,"scroll_height":1870,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629702691}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":16,"scroll_height":1850,"client_height":1390,"host_height":1516,"box_height":1513.5999755859375},"timestamp":1768629702693}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1562,"client_height":967,"host_height":1093,"box_height":1090.5999755859375},"timestamp":1768629702693}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":0,"heights":14,"applied":0,"scroll_height":1926,"client_height":1926,"host_height":2074.300048828125,"box_height":2050.300048828125},"timestamp":1768630824934}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5491133230322@s.whatsapp.net","scroll_height":1926,"client_height":1926,"host_height":2073.5,"box_height":2049.5},"timestamp":1768630824934}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":14,"heights":14,"applied":14,"scroll_height":2362,"client_height":2362,"host_height":2510,"box_height":2486},"timestamp":1768630824939}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":14,"applied":0,"scroll_height":1522,"client_height":1522,"host_height":1669.75,"box_height":1645.75},"timestamp":1768630848988}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":12,"applied":12,"scroll_height":1925,"client_height":1925,"host_height":2073,"box_height":2049},"timestamp":1768630849001}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5421133230322@s.whatsapp.net","scroll_height":1924,"client_height":1924,"host_height":2072.199951171875,"box_height":2048.199951171875},"timestamp":1768630849002}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":12,"heights":16,"applied":0,"scroll_height":1841,"client_height":1841,"host_height":1988.7000732421875,"box_height":1964.7000732421875},"timestamp":1768630855088}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5492233230322@s.whatsapp.net","scroll_height":1840,"client_height":1840,"host_height":1987.9000244140625,"box_height":1963.9000244140625},"timestamp":1768630855089}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":16,"scroll_height":2389,"client_height":2389,"host_height":2537,"box_height":2513},"timestamp":1768630855093}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":0,"scroll_height":2096,"client_height":2096,"host_height":2243.800048828125,"box_height":2219.800048828125},"timestamp":1768630857542}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H16","location":"conversation-inspector.js:248","message":"apply_heights","data":{"rows":16,"heights":16,"applied":16,"scroll_height":2621,"client_height":2621,"host_height":2769,"box_height":2745},"timestamp":1768630857552}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":2620,"client_height":2620,"host_height":2768.199951171875,"box_height":2744.199951171875},"timestamp":1768630857552}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":14,"chat_id":"5491133230322@s.whatsapp.net","scroll_height":1366,"client_height":1136,"host_height":1284,"box_height":1260},"timestamp":1768631476665}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5421133230322@s.whatsapp.net","scroll_height":1555,"client_height":1156,"host_height":1304,"box_height":1280},"timestamp":1768631805322}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":12,"chat_id":"5421133230322@s.whatsapp.net","scroll_height":1555,"client_height":1156,"host_height":1304,"box_height":1280},"timestamp":1768632738993}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5432233230322@s.whatsapp.net","scroll_height":1868,"client_height":1196,"host_height":1344,"box_height":1320},"timestamp":1768633339517}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5492233230322@s.whatsapp.net","scroll_height":1674,"client_height":1046,"host_height":1194,"box_height":1170},"timestamp":1768633558760}
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"H15","location":"run-timeline.js:180","message":"bubbles_layout","data":{"count":16,"chat_id":"5492233230322@s.whatsapp.net","scroll_height":1674,"client_height":1046,"host_height":1194,"box_height":1170},"timestamp":1768633644474}

View File

@@ -0,0 +1,24 @@
-- migrate:up
create table if not exists product_reco_rules (
id bigserial primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
rule_key text not null,
trigger jsonb not null default '{}'::jsonb,
queries jsonb not null default '[]'::jsonb,
boosts jsonb not null default '{}'::jsonb,
ask_slots jsonb not null default '[]'::jsonb,
active boolean not null default true,
priority integer not null default 100,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, rule_key)
);
create index if not exists product_reco_rules_tenant_idx
on product_reco_rules (tenant_id);
create index if not exists product_reco_rules_active_idx
on product_reco_rules (tenant_id, active, priority);
-- migrate:down
drop table if exists product_reco_rules;

View File

@@ -0,0 +1,49 @@
-- migrate:up
insert into product_reco_rules
(tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
select
t.id as tenant_id,
'asado_core' as rule_key,
jsonb_build_object(
'keywords', jsonb_build_array('asado', 'parrilla', 'bife', 'entraña', 'vacio', 'vacío')
) as trigger,
jsonb_build_array('provoleta', 'chimichurri', 'ensalada', 'pan') as queries,
'{}'::jsonb as boosts,
jsonb_build_array(
jsonb_build_object('slot','alcohol','question','¿Tomás alcohol?')
) as ask_slots,
true as active,
100 as priority
from tenants t
on conflict (tenant_id, rule_key) do nothing;
insert into product_reco_rules
(tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
select
t.id as tenant_id,
'alcohol_yes' as rule_key,
jsonb_build_object('always', true, 'alcohol', true) as trigger,
jsonb_build_array('vino tinto', 'malbec', 'cabernet') as queries,
'{}'::jsonb as boosts,
'[]'::jsonb as ask_slots,
true as active,
200 as priority
from tenants t
on conflict (tenant_id, rule_key) do nothing;
insert into product_reco_rules
(tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
select
t.id as tenant_id,
'alcohol_no' as rule_key,
jsonb_build_object('always', true, 'alcohol', false) as trigger,
jsonb_build_array('agua con gas', 'gaseosa', 'limonada') as queries,
'{}'::jsonb as boosts,
'[]'::jsonb as ask_slots,
true as active,
210 as priority
from tenants t
on conflict (tenant_id, rule_key) do nothing;
-- migrate:down
delete from product_reco_rules where rule_key in ('asado_core','alcohol_yes','alcohol_no');

44
env.example Normal file
View File

@@ -0,0 +1,44 @@
# Botino - Example Environment Variables
# Copy this file to .env and fill in the values
# ===================
# Core
# ===================
PORT=3000
TENANT_KEY=piaf
DATABASE_URL=postgresql://user:password@localhost:5432/botino
PG_POOL_MAX=10
PG_IDLE_TIMEOUT_MS=30000
PG_CONN_TIMEOUT_MS=5000
APP_ENCRYPTION_KEY=your-32-char-encryption-key-here
# ===================
# OpenAI
# ===================
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-4o-mini
# ===================
# Turn Engine
# ===================
# v1 = pipeline actual (heurísticas + guardrails + LLM plan final)
# v2 = LLM-first NLU, deterministic core (nuevo motor)
TURN_ENGINE=v1
# ===================
# WooCommerce (fallback si falta config por tenant)
# ===================
WOO_BASE_URL=https://tu-tienda.com
WOO_CONSUMER_KEY=ck_xxx
WOO_CONSUMER_SECRET=cs_xxx
# ===================
# Debug Flags (1/true/yes/on para activar)
# ===================
DEBUG_PERF=0
DEBUG_WOO_HTTP=0
DEBUG_WOO_PRODUCTS=0
DEBUG_LLM=0
DEBUG_EVOLUTION=0
DEBUG_DB=0
DEBUG_RESOLVE=0

View File

@@ -1,8 +1,12 @@
import "./components/ops-shell.js"; import "./components/ops-shell.js";
import "./components/conversation-list.js";
import "./components/run-timeline.js"; import "./components/run-timeline.js";
import "./components/chat-simulator.js"; import "./components/chat-simulator.js";
import "./components/debug-panel.js"; import "./components/conversation-inspector.js";
import "./components/conversations-crud.js";
import "./components/users-crud.js";
import "./components/products-crud.js";
import "./components/aliases-crud.js";
import "./components/recommendations-crud.js";
import { connectSSE } from "./lib/sse.js"; import { connectSSE } from "./lib/sse.js";
connectSSE(); connectSSE();

View File

@@ -0,0 +1,278 @@
import { api } from "../lib/api.js";
class AliasesCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.products = [];
this.selected = null;
this.loading = false;
this.searchQuery = "";
this.editMode = null; // 'create' | 'edit' | null
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 400px; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
.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:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
.item-product { font-size:12px; color:#8aa0b5; }
.item-boost { color:#2ecc71; font-size:11px; }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Equivalencias (Aliases)</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar alias..." style="flex:1" />
<button id="newBtn">+ Nuevo</button>
</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="formTitle">Detalle</div>
<div class="form" id="form">
<div class="form-empty">Seleccioná un alias o creá uno nuevo</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.load(), 300);
};
this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm();
this.load();
this.loadProducts();
}
async load() {
this.loading = true;
this.renderList();
try {
const data = await api.aliases({ q: this.searchQuery, limit: 500 });
this.items = data.items || [];
this.loading = false;
this.renderList();
} catch (e) {
console.error("Error loading aliases:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
async loadProducts() {
try {
const data = await api.products({ limit: 500 });
this.products = data.items || [];
} catch (e) {
console.error("Error loading products:", e);
this.products = [];
}
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron aliases</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
const product = this.products.find(p => p.woo_product_id === item.woo_product_id);
const productName = product?.name || `ID: ${item.woo_product_id || "—"}`;
const boost = item.boost ? `+${item.boost}` : "";
el.innerHTML = `
<div class="item-alias">"${item.alias}"</div>
<div class="item-product">→ ${productName} ${boost ? `<span class="item-boost">(boost: ${boost})</span>` : ""}</div>
`;
el.onclick = () => {
this.selected = item;
this.editMode = "edit";
this.renderList();
this.renderForm();
};
list.appendChild(el);
}
}
showCreateForm() {
this.selected = null;
this.editMode = "create";
this.renderList();
this.renderForm();
}
renderForm() {
const form = this.shadowRoot.getElementById("form");
const title = this.shadowRoot.getElementById("formTitle");
if (!this.editMode) {
title.textContent = "Detalle";
form.innerHTML = `<div class="form-empty">Seleccioná un alias o creá uno nuevo</div>`;
return;
}
const isCreate = this.editMode === "create";
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
const alias = this.selected?.alias || "";
const wooProductId = this.selected?.woo_product_id || "";
const boost = this.selected?.boost || 0;
const categoryHint = this.selected?.category_hint || "";
const productOptions = this.products.map(p =>
`<option value="${p.woo_product_id}" ${p.woo_product_id === wooProductId ? "selected" : ""}>${p.name}</option>`
).join("");
form.innerHTML = `
<div class="field">
<label>Alias (lo que dice el usuario)</label>
<input type="text" id="aliasInput" value="${alias}" ${isCreate ? "" : "disabled"} placeholder="ej: chimi, vacio, bife" />
<div class="field-hint">Sin tildes, en minusculas. Ej: "chimi" mapea a "Chimichurri"</div>
</div>
<div class="field">
<label>Producto destino</label>
<select id="productInput">
<option value="">— Seleccionar producto —</option>
${productOptions}
</select>
</div>
<div class="field">
<label>Boost (puntuacion extra)</label>
<input type="number" id="boostInput" value="${boost}" min="0" max="10" step="0.1" />
<div class="field-hint">Valor entre 0 y 10. Mayor boost = mayor prioridad en resultados</div>
</div>
<div class="field">
<label>Categoria hint (opcional)</label>
<input type="text" id="categoryInput" value="${categoryHint}" placeholder="ej: carnes, bebidas" />
</div>
<div class="actions">
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
<button id="cancelBtn" class="secondary">Cancelar</button>
</div>
`;
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
if (!isCreate) {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
}
async save() {
const aliasInput = this.shadowRoot.getElementById("aliasInput").value.trim().toLowerCase();
const productInput = this.shadowRoot.getElementById("productInput").value;
const boostInput = parseFloat(this.shadowRoot.getElementById("boostInput").value) || 0;
const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
if (!aliasInput) {
alert("El alias es requerido");
return;
}
if (!productInput) {
alert("Seleccioná un producto");
return;
}
const data = {
alias: aliasInput,
woo_product_id: parseInt(productInput, 10),
boost: boostInput,
category_hint: categoryInput || null,
};
try {
if (this.editMode === "create") {
await api.createAlias(data);
} else {
await api.updateAlias(this.selected.alias, data);
}
this.editMode = null;
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error saving alias:", e);
alert("Error guardando: " + (e.message || e));
}
}
async delete() {
if (!this.selected?.alias) return;
if (!confirm(`¿Eliminar el alias "${this.selected.alias}"?`)) return;
try {
await api.deleteAlias(this.selected.alias);
this.editMode = null;
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error deleting alias:", e);
alert("Error eliminando: " + (e.message || e));
}
}
cancel() {
this.editMode = null;
this.selected = null;
this.renderList();
this.renderForm();
}
}
customElements.define("aliases-crud", AliasesCrud);

View File

@@ -9,39 +9,60 @@ class ChatSimulator extends HTMLElement {
this._sending = false; this._sending = false;
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
:host { display:block; padding:12px; } :host { display:block; height:100%; overflow:hidden; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; margin-bottom:10px; } * { box-sizing:border-box; }
.muted { color:#8aa0b5; font-size:12px; } .container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; overflow:hidden; }
.col { display:flex; flex-direction:column; padding:10px 12px; border-right:1px solid #1e2a3a; min-width:0; overflow:hidden; }
.col:last-child { border-right:none; }
.muted { color:#8aa0b5; font-size:11px; margin-bottom:6px; }
.row { display:flex; gap:8px; align-items:center; } .row { display:flex; gap:8px; align-items:center; }
input,textarea,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; } input,textarea,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:6px; padding:6px 8px; font-size:12px; box-sizing:border-box; }
textarea { width:100%; min-height:70px; resize:vertical; } input { width:100%; min-width:0; }
button { cursor:pointer; } textarea { width:100%; resize:none; height:186px; min-width:0; margin-bottom:10px;}
button { cursor:pointer; white-space:nowrap; }
button.primary { background:#1f6feb; border-color:#1f6feb; } button.primary { background:#1f6feb; border-color:#1f6feb; }
button:disabled { opacity:.6; cursor:not-allowed; } button:disabled { opacity:.6; cursor:not-allowed; }
.status { margin-top:8px; font-size:12px; color:#8aa0b5; } .status { font-size:11px; color:#8aa0b5; margin-top:6px; }
.inputs-col { display:flex; flex-direction:column; gap:6px; flex:1; overflow:hidden; min-width:0; }
.field { display:flex; flex-direction:column; min-width:0; }
.field label { font-size:10px; color:#8aa0b5; margin-bottom:2px; }
.msg-col { display:flex; flex-direction:column; min-width:0; }
.msg-bottom { display:flex; gap:8px; align-items:center; margin-top:6px; }
</style> </style>
<div class="box"> <div class="container">
<div class="muted">Evolution Sim (único chat)</div> <div class="col">
<div class="row" style="margin-top:8px"> <div class="muted">Evolution Sim</div>
<input id="instance" style="flex:1" value="Piaf" placeholder="tenant/instance key" /> <div class="inputs-col">
<div class="field">
<label>Instance</label>
<input id="instance" value="Piaf" placeholder="tenant" />
</div>
<div class="field">
<label>From (remoteJid)</label>
<input id="evoFrom" value="5491133230322@s.whatsapp.net" placeholder="from" />
</div>
<div class="field">
<label>To</label>
<input id="evoTo" value="5491137887040@s.whatsapp.net" placeholder="to" />
</div>
<div class="field">
<label>Push Name</label>
<input id="pushName" value="test_lucas" placeholder="pushName" />
</div>
</div>
</div> </div>
<div class="row" style="margin-top:8px"> <div class="col">
<input id="evoFrom" style="flex:1" value="5491133230322@s.whatsapp.net" placeholder="from (remoteJid)" /> <div class="muted">Mensaje</div>
<div class="msg-col">
<textarea id="evoText" placeholder="Texto a enviar..."></textarea>
<div class="msg-bottom">
<button class="primary" id="sendEvo">Send</button>
<button id="retry">Retry</button>
<span class="status" id="status">—</span>
</div>
</div>
</div> </div>
<div class="row" style="margin-top:8px">
<input id="evoTo" style="flex:1" value="5491137887040@s.whatsapp.net" placeholder="to (destino receptor)" />
</div>
<div class="row" style="margin-top:8px">
<input id="pushName" style="flex:1" value="test_lucas" placeholder="pushName (opcional)" />
</div>
<div class="muted" style="margin-top:7px">Enviar mensaje</div>
<textarea id="evoText" style="margin-top:8px" placeholder="Texto a enviar por Evolution…"></textarea>
<div class="row" style="margin-top:8px">
<button class="primary" id="sendEvo" style="flex:1">Send</button>
<button id="retry" style="width:140px">Retry last</button>
</div>
<div class="status" id="status">—</div>
</div> </div>
`; `;
} }
@@ -123,6 +144,17 @@ class ChatSimulator extends HTMLElement {
last_run_id: null, last_run_id: null,
}); });
emit("ui:selectedChat", { chat_id: from }); emit("ui:selectedChat", { chat_id: from });
// Optimistic: mostrar burbuja del usuario inmediatamente
emit("message:optimistic", {
chat_id: from,
message_id: `optimistic-${Date.now()}`,
direction: "in",
text,
ts: new Date().toISOString(),
provider: "sim",
pushName: pushName || "test_lucas",
});
const payload = payloadOverride || buildPayload({ text, from, to, instance, pushName }); const payload = payloadOverride || buildPayload({ text, from, to, instance, pushName });
this._lastPayload = { ...payload, body: { ...payload.body, data: { ...payload.body.data, key: { ...payload.body.data.key, id: genId() } } } }; this._lastPayload = { ...payload, body: { ...payload.body, data: { ...payload.body.data, key: { ...payload.body.data.key, id: genId() } } } };

View File

@@ -0,0 +1,308 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
class ConversationInspector extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.chatId = null;
this.messages = [];
this.runs = [];
this.rowOrder = [];
this.rowMap = new Map();
this.heights = new Map();
this._playing = false;
this._playIdx = 0;
this._timer = null;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; height:100%; overflow:hidden; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
.row { display:flex; gap:8px; align-items:center; }
.muted { color:#8aa0b5; font-size:12px; }
.title { font-weight:800; }
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
.item { border:1px solid #253245; border-radius:12px; padding:8px 10px; background:#0f1520; font-size:12px; margin-bottom:12px; box-sizing:border-box; }
.item.in { background:#0f1520; border-color:#2a3a55; }
.item.out { background:#111b2a; border-color:#2a3a55; }
.item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
.kv { display:grid; grid-template-columns:70px 1fr; gap:6px 10px; }
.k { color:#8aa0b5; font-size:11px; letter-spacing:.4px; text-transform:uppercase; }
.v { font-size:12px; color:#e7eef7; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
.chip { display:inline-flex; align-items:center; gap:6px; padding:2px 6px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:11px; color:#8aa0b5; }
.cart { margin-top:6px; font-size:11px; color:#c7d8ee; }
.tool { margin-top:6px; font-size:11px; color:#8aa0b5; }
.dot { width:8px; height:8px; border-radius:50%; }
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
</style>
<div class="box">
<div class="muted">Inspector</div>
<div class="title" id="chat">—</div>
<div class="muted" id="meta">Seleccioná una conversación.</div>
<div class="toolbar">
<button id="play">Play</button>
<button id="pause">Pause</button>
<button id="step">Step</button>
<span class="muted" id="count"></span>
</div>
<div class="list" id="list"></div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("play").onclick = () => this.play();
this.shadowRoot.getElementById("pause").onclick = () => this.pause();
this.shadowRoot.getElementById("step").onclick = () => this.step();
this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => {
this.chatId = chat_id;
await this.loadData();
});
this._unsubRun = on("run:created", (run) => {
if (this.chatId && run.chat_id === this.chatId) {
this.loadData();
}
});
this._unsubLayout = on("ui:bubblesLayout", ({ chat_id, items }) => {
if (!this.chatId || chat_id !== this.chatId) return;
this.heights = new Map((items || []).map((it) => [it.message_id, it.height || 0]));
this.applyHeights();
});
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => {
if (!this.chatId || chat_id !== this.chatId) return;
const list = this.shadowRoot.getElementById("list");
list.scrollTop = scrollTop || 0;
});
this._unsubSelectMessage = on("ui:selectedMessage", ({ message }) => {
const messageId = message?.message_id || null;
if (messageId) this.highlight(messageId);
});
}
disconnectedCallback() {
this._unsubSel?.();
this._unsubRun?.();
this._unsubLayout?.();
this._unsubScroll?.();
this._unsubSelectMessage?.();
this.pause();
}
async loadData() {
const chatEl = this.shadowRoot.getElementById("chat");
const metaEl = this.shadowRoot.getElementById("meta");
const countEl = this.shadowRoot.getElementById("count");
const list = this.shadowRoot.getElementById("list");
chatEl.textContent = this.chatId || "—";
metaEl.textContent = "Cargando…";
countEl.textContent = "";
list.innerHTML = "";
if (!this.chatId) {
metaEl.textContent = "Seleccioná una conversación.";
return;
}
try {
const [msgs, runs] = await Promise.all([
api.messages({ chat_id: this.chatId, limit: 200 }),
api.runs({ chat_id: this.chatId, limit: 200 }),
]);
this.messages = msgs.items || [];
this.runs = runs.items || [];
this.render();
this.applyHeights();
} catch (e) {
metaEl.textContent = `Error cargando: ${String(e?.message || e)}`;
this.messages = [];
this.runs = [];
}
}
runMap() {
const map = new Map();
for (const r of this.runs || []) {
map.set(r.run_id, r);
}
return map;
}
buildRows() {
const runsById = this.runMap();
const rows = [];
let nextRun = null;
for (let i = this.messages.length - 1; i >= 0; i--) {
const msg = this.messages[i];
const run = msg?.run_id ? runsById.get(msg.run_id) : null;
if (run) nextRun = run;
rows[i] = { message: msg, run, nextRun };
}
return rows;
}
formatCart(items) {
const list = Array.isArray(items) ? items : [];
if (!list.length) return "—";
return list
.map((it) => {
const label = it.label || it.name || `#${it.product_id}`;
const qty = it.quantity != null ? `${it.quantity}` : "?";
const unit = it.unit || "";
return `${label} (${qty}${unit ? " " + unit : ""})`;
})
.join(" · ");
}
toolSummary(tools = []) {
return tools.map((t) => ({
type: t.type || t.name || "tool",
ok: t.ok !== false,
error: t.error || null,
}));
}
render() {
const metaEl = this.shadowRoot.getElementById("meta");
const countEl = this.shadowRoot.getElementById("count");
const list = this.shadowRoot.getElementById("list");
metaEl.textContent = `Inspector de ${this.messages.length} mensajes.`;
countEl.textContent = this.messages.length ? `${this.messages.length} filas` : "";
list.innerHTML = "";
this.rowMap.clear();
this.rowOrder = [];
const rows = this.buildRows();
for (const row of rows) {
const msg = row.message;
const run = row.run;
const dir = msg?.direction === "in" ? "in" : "out";
const el = document.createElement("div");
el.className = `item ${dir}`;
el.dataset.messageId = msg.message_id;
const intent = run?.llm_output?.intent || "—";
const nextState = run?.llm_output?.next_state || "—";
const prevState = row.nextRun?.prev_state || "—";
const basket = run?.llm_output?.basket_resolved?.items || [];
const tools = this.toolSummary(run?.tools || []);
const llmMeta = run?.llm_output?._llm || null;
const llmStatus = llmMeta?.audit?.validation?.ok === false ? "warn" : "ok";
const llmNote = llmMeta?.audit?.validation?.ok === false
? "NLU inválido (fallback)"
: llmMeta?.audit?.validation?.retried
? "NLU ok (retry)"
: "NLU ok";
el.innerHTML = `
<div class="kv">
<div class="k">${dir === "in" ? "IN" : "OUT"}</div>
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
<div class="k">STATE</div>
<div class="v">${dir === "out" ? nextState : prevState}</div>
<div class="k">INTENT</div>
<div class="v">${dir === "out" ? intent : "—"}</div>
<div class="k">NLU</div>
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
</div>
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatCart(basket) : "—"}</div>
<div class="chips">
${tools
.map(
(t) =>
`<span class="chip"><span class="dot ${t.ok ? "ok" : "err"}"></span>${t.type}</span>`
)
.join("")}
</div>
`;
el.onclick = () => {
this.highlight(msg.message_id);
};
list.appendChild(el);
this.rowMap.set(msg.message_id, el);
this.rowOrder.push(msg.message_id);
}
}
applyHeights() {
const BUBBLE_MARGIN = 12; // same as .bubble margin-bottom in run-timeline
const MIN_ITEM_HEIGHT = 120; // minimum height for inspector items
for (const [messageId, el] of this.rowMap.entries()) {
const bubbleHeight = this.heights.get(messageId) || 0;
// bubbleHeight includes offsetHeight + marginBottom
const bubbleContentHeight = Math.max(0, bubbleHeight - BUBBLE_MARGIN);
// Use the max between bubble height and our minimum
const targetHeight = Math.max(bubbleContentHeight, MIN_ITEM_HEIGHT);
el.style.minHeight = `${targetHeight}px`;
el.style.marginBottom = `${BUBBLE_MARGIN}px`;
}
// After applying, emit final heights back so bubbles can sync
requestAnimationFrame(() => this.emitInspectorHeights());
}
emitInspectorHeights() {
const items = [];
for (const [messageId, el] of this.rowMap.entries()) {
const styles = window.getComputedStyle(el);
const marginBottom = parseInt(styles.marginBottom || "0", 10) || 0;
items.push({
message_id: messageId,
height: (el.offsetHeight || 0) + marginBottom,
});
}
emit("ui:inspectorLayout", { chat_id: this.chatId, items });
}
highlight(messageId) {
for (const [id, el] of this.rowMap.entries()) {
el.classList.toggle("active", id === messageId);
}
emit("ui:highlightMessage", { message_id: messageId });
const idx = this.rowOrder.indexOf(messageId);
if (idx >= 0) this._playIdx = idx;
}
play() {
if (this._playing) return;
this._playing = true;
this._timer = setInterval(() => this.step(), 800);
}
pause() {
this._playing = false;
if (this._timer) clearInterval(this._timer);
this._timer = null;
}
step() {
if (!this.rowOrder.length) return;
if (this._playIdx >= this.rowOrder.length) {
this.pause();
return;
}
const messageId = this.rowOrder[this._playIdx];
this.highlight(messageId);
this._playIdx += 1;
}
}
customElements.define("conversation-inspector", ConversationInspector);

View File

@@ -0,0 +1,267 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
class ConversationsCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
this.loading = false;
this.searchQuery = "";
this.statusFilter = "";
this.stateFilter = "";
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
.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:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-header { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
.item-name { font-weight:600; color:#e7eef7; flex:1; }
.item-meta { font-size:12px; color:#8aa0b5; }
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; }
.detail { flex:1; overflow-y:auto; }
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Conversaciones</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar chat_id o telefono..." style="flex:1;min-width:150px" />
<select id="status">
<option value="">Status: todos</option>
<option value="ok">ok</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
<select id="state">
<option value="">State: todos</option>
<option>IDLE</option>
<option>BROWSING</option>
<option>BUILDING_ORDER</option>
<option>WAITING_ADDRESS</option>
<option>WAITING_PAYMENT</option>
<option>COMPLETED</option>
</select>
</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="detailTitle">Detalle</div>
<div class="detail" id="detail">
<div class="detail-empty">Selecciona una conversacion</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.load(), 300);
};
this.shadowRoot.getElementById("status").onchange = (e) => {
this.statusFilter = e.target.value;
this.load();
};
this.shadowRoot.getElementById("state").onchange = (e) => {
this.stateFilter = e.target.value;
this.load();
};
this._unsubUpsert = on("conversation:upsert", (conv) => {
const idx = this.items.findIndex(x => x.chat_id === conv.chat_id);
if (idx >= 0) this.items[idx] = conv;
else this.items.unshift(conv);
this.renderList();
});
this.load();
}
disconnectedCallback() {
this._unsubUpsert?.();
}
async load() {
this.loading = true;
this.renderList();
try {
const data = await api.conversations({
q: this.searchQuery,
status: this.statusFilter,
state: this.stateFilter
});
this.items = data.items || [];
this.loading = false;
this.renderList();
} catch (e) {
console.error("Error loading conversations:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron conversaciones</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : "");
const dotClass = item.status === "ok" ? "ok" : (item.status === "warn" ? "warn" : "err");
const time = item.last_activity ? new Date(item.last_activity).toLocaleString() : "—";
el.innerHTML = `
<div class="item-header">
<span class="dot ${dotClass}"></span>
<span class="item-name">${item.from || item.chat_id}</span>
</div>
<div class="item-meta">${item.chat_id}</div>
<div class="chips">
<span class="chip">state: ${item.state || "—"}</span>
<span class="chip">intent: ${item.intent || "—"}</span>
<span class="chip">${time}</span>
</div>
`;
el.onclick = () => {
this.selected = item;
this.renderList();
this.renderDetail();
};
list.appendChild(el);
}
}
renderDetail() {
const detail = this.shadowRoot.getElementById("detail");
const title = this.shadowRoot.getElementById("detailTitle");
if (!this.selected) {
title.textContent = "Detalle";
detail.innerHTML = `<div class="detail-empty">Selecciona una conversacion</div>`;
return;
}
const c = this.selected;
title.textContent = c.from || c.chat_id;
detail.innerHTML = `
<div class="field">
<label>Chat ID</label>
<div class="field-value">${c.chat_id}</div>
</div>
<div class="field">
<label>From</label>
<div class="field-value">${c.from || "—"}</div>
</div>
<div class="field">
<label>Estado</label>
<div class="field-value">${c.state || "—"}</div>
</div>
<div class="field">
<label>Intent</label>
<div class="field-value">${c.intent || "—"}</div>
</div>
<div class="field">
<label>Status</label>
<div class="field-value">${c.status || "—"}</div>
</div>
<div class="field">
<label>Ultima actividad</label>
<div class="field-value">${c.last_activity ? new Date(c.last_activity).toLocaleString() : "—"}</div>
</div>
<div class="field">
<label>Ultimo run</label>
<div class="field-value">${c.last_run_id || "—"}</div>
</div>
<div class="actions">
<button id="openChat">Abrir Chat</button>
<button id="retryLast" class="secondary">Retry Last</button>
<button id="deleteConv" class="danger">Eliminar</button>
</div>
`;
detail.scrollTop = 0;
this.shadowRoot.getElementById("openChat").onclick = () => {
emit("ui:selectedChat", { chat_id: c.chat_id });
emit("ui:switchView", { view: "chat" });
};
this.shadowRoot.getElementById("retryLast").onclick = async () => {
try {
await api.retryLast(c.chat_id);
alert("Retry ejecutado");
} catch (e) {
alert("Error: " + (e.message || e));
}
};
this.shadowRoot.getElementById("deleteConv").onclick = async () => {
if (!confirm(`¿Eliminar la conversacion de "${c.chat_id}"?`)) return;
try {
await api.deleteConversation(c.chat_id);
this.selected = null;
await this.load();
this.renderDetail();
} catch (e) {
alert("Error: " + (e.message || e));
}
};
}
}
customElements.define("conversations-crud", ConversationsCrud);

View File

@@ -4,38 +4,87 @@ class OpsShell extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this._currentView = "chat";
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
:host { --bg:#0b0f14; --panel:#121823; --muted:#8aa0b5; --text:#e7eef7; --line:#1e2a3a; --blue:#1f6feb; } :host { --bg:#0b0f14; --panel:#121823; --muted:#8aa0b5; --text:#e7eef7; --line:#1e2a3a; --blue:#1f6feb; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; } * { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.app { height:100vh; background:var(--bg); color:var(--text); display:flex; flex-direction:column; } .app { height:100vh; background:var(--bg); color:var(--text); display:flex; flex-direction:column; }
header { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line); } header { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
header h1 { font-size:14px; margin:0; color:var(--muted); font-weight:700; letter-spacing:.4px; text-transform:uppercase; } header h1 { font-size:14px; margin:0; color:var(--muted); font-weight:700; letter-spacing:.4px; text-transform:uppercase; }
.nav { display:flex; gap:4px; margin-left:24px; flex-wrap:wrap; }
.nav-btn { background:transparent; border:1px solid var(--line); color:var(--muted); padding:6px 12px; border-radius:6px; font-size:12px; cursor:pointer; transition:all .15s; }
.nav-btn:hover { border-color:var(--blue); color:var(--text); }
.nav-btn.active { background:var(--blue); border-color:var(--blue); color:#fff; }
.spacer { flex:1; } .spacer { flex:1; }
.status { font-size:12px; color:var(--muted); } .status { font-size:12px; color:var(--muted); }
.layout { flex:1; display:grid; grid-template-columns:320px 1fr 360px; min-height:0; }
.col { border-right:1px solid var(--line); min-height:0; overflow:auto; } /* Layout para chat activo (2 columnas: burbujas + inspector) */
.col:last-child { border-right:none; } .layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
.mid { display:flex; flex-direction:column; min-height:0; } .col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
.midTop { flex:1; min-height:0; overflow:auto; border-bottom:1px solid var(--line); } .chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--line); }
.midBottom { min-height:220px; overflow:auto; } .chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--line); }
.inspectorTop { grid-column:2; grid-row:1; border-right:none; }
/* Layout para CRUDs */
.layout-crud { height:100%; display:block; min-height:0; overflow:hidden; }
.view { display:none; flex:1; min-height:0; overflow:hidden; }
.view.active { display:flex; flex-direction:column; }
</style> </style>
<div class="app"> <div class="app">
<header> <header>
<h1>Bot Ops Console</h1> <h1>Bot Ops Console</h1>
<nav class="nav">
<button class="nav-btn active" data-view="chat">Chat</button>
<button class="nav-btn" data-view="conversations">Conversaciones</button>
<button class="nav-btn" data-view="users">Usuarios</button>
<button class="nav-btn" data-view="products">Productos</button>
<button class="nav-btn" data-view="aliases">Equivalencias</button>
<button class="nav-btn" data-view="recommendations">Recomendaciones</button>
</nav>
<div class="spacer"></div> <div class="spacer"></div>
<div class="status" id="sseStatus">SSE: connecting…</div> <div class="status" id="sseStatus">SSE: connecting…</div>
</header> </header>
<div class="layout"> <div id="viewChat" class="view active">
<div class="col"><conversation-list></conversation-list></div> <div class="layout-chat">
<div class="col mid"> <div class="col chatTop"><run-timeline></run-timeline></div>
<div class="midTop"><run-timeline></run-timeline></div> <div class="col inspectorTop"><conversation-inspector></conversation-inspector></div>
<div class="midBottom"><chat-simulator></chat-simulator></div> <div class="col chatBottom"><chat-simulator></chat-simulator></div>
</div>
</div>
<div id="viewConversations" class="view">
<div class="layout-crud">
<conversations-crud></conversations-crud>
</div>
</div>
<div id="viewUsers" class="view">
<div class="layout-crud">
<users-crud></users-crud>
</div>
</div>
<div id="viewProducts" class="view">
<div class="layout-crud">
<products-crud></products-crud>
</div>
</div>
<div id="viewAliases" class="view">
<div class="layout-crud">
<aliases-crud></aliases-crud>
</div>
</div>
<div id="viewRecommendations" class="view">
<div class="layout-crud">
<recommendations-crud></recommendations-crud>
</div> </div>
<div class="col"><debug-panel></debug-panel></div>
</div> </div>
</div> </div>
`; `;
@@ -46,10 +95,39 @@ class OpsShell extends HTMLElement {
const el = this.shadowRoot.getElementById("sseStatus"); const el = this.shadowRoot.getElementById("sseStatus");
el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)"; el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)";
}); });
// Listen for view switch requests from other components
this._unsubSwitch = on("ui:switchView", ({ view }) => {
if (view) this.setView(view);
});
// Navigation
const navBtns = this.shadowRoot.querySelectorAll(".nav-btn");
for (const btn of navBtns) {
btn.onclick = () => this.setView(btn.dataset.view);
}
} }
disconnectedCallback() { disconnectedCallback() {
this._unsub?.(); this._unsub?.();
this._unsubSwitch?.();
}
setView(viewName) {
this._currentView = viewName;
// Update nav buttons
const navBtns = this.shadowRoot.querySelectorAll(".nav-btn");
for (const btn of navBtns) {
btn.classList.toggle("active", btn.dataset.view === viewName);
}
// Update views
const views = this.shadowRoot.querySelectorAll(".view");
for (const view of views) {
const isActive = view.id === `view${viewName.charAt(0).toUpperCase() + viewName.slice(1)}`;
view.classList.toggle("active", isActive);
}
} }
} }

View File

@@ -0,0 +1,267 @@
import { api } from "../lib/api.js";
class ProductsCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
this.loading = false;
this.searchQuery = "";
this.stockFilter = false;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 400px; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
input { flex:1; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
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:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
.item-meta { font-size:12px; color:#8aa0b5; }
.item-price { color:#2ecc71; font-weight:600; }
.detail { flex:1; overflow-y:auto; }
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
.field-value.json { font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
.stats { display:flex; gap:16px; margin-bottom:16px; }
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
.stat:hover { border-color:#1f6feb; }
.stat.active { border-color:#1f6feb; background:#111b2a; }
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
.badge.stock { background:#0f2a1a; color:#2ecc71; }
.badge.nostock { background:#241214; color:#e74c3c; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Productos</div>
<div class="stats">
<div class="stat" id="statTotal">
<div class="stat-value" id="totalCount">—</div>
<div class="stat-label">Total</div>
</div>
<div class="stat" id="statStock">
<div class="stat-value" id="inStockCount">—</div>
<div class="stat-label">En Stock</div>
</div>
</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar por nombre o SKU..." />
<button id="syncBtn" class="secondary">Sync Woo</button>
</div>
<div class="list" id="list">
<div class="loading">Cargando productos...</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Detalle</div>
<div class="detail" id="detail">
<div class="detail-empty">Seleccioná un producto para ver detalles</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.load(), 300);
};
this.shadowRoot.getElementById("syncBtn").onclick = () => this.syncFromWoo();
// Stats click handlers
this.shadowRoot.getElementById("statTotal").onclick = () => {
this.stockFilter = false;
this.renderList();
this.updateStatStyles();
};
this.shadowRoot.getElementById("statStock").onclick = () => {
this.stockFilter = !this.stockFilter;
this.renderList();
this.updateStatStyles();
};
this.load();
}
updateStatStyles() {
const statTotal = this.shadowRoot.getElementById("statTotal");
const statStock = this.shadowRoot.getElementById("statStock");
statTotal.classList.toggle("active", !this.stockFilter);
statStock.classList.toggle("active", this.stockFilter);
}
async load() {
this.loading = true;
this.renderList();
try {
const data = await api.products({ q: this.searchQuery, limit: 2000 });
this.items = data.items || [];
this.loading = false;
this.renderList();
this.renderStats();
} catch (e) {
console.error("Error loading products:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
async syncFromWoo() {
const btn = this.shadowRoot.getElementById("syncBtn");
btn.disabled = true;
btn.textContent = "Sincronizando...";
try {
await api.syncProducts();
await this.load();
} catch (e) {
console.error("Error syncing products:", e);
alert("Error sincronizando: " + (e.message || e));
} finally {
btn.disabled = false;
btn.textContent = "Sync Woo";
}
}
renderStats() {
const total = this.items.length;
const inStock = this.items.filter(p => p.stock_status === "instock" || p.payload?.stock_status === "instock").length;
this.shadowRoot.getElementById("totalCount").textContent = total;
this.shadowRoot.getElementById("inStockCount").textContent = inStock;
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando productos...</div>`;
return;
}
// Filter items based on stock filter
const filteredItems = this.stockFilter
? this.items.filter(p => p.stock_status === "instock" || p.payload?.stock_status === "instock")
: this.items;
if (!filteredItems.length) {
list.innerHTML = `<div class="loading">No se encontraron productos</div>`;
return;
}
list.innerHTML = "";
for (const item of filteredItems) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.woo_product_id === item.woo_product_id ? " active" : "");
const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
const sku = item.sku || "—";
const stock = item.stock_status || item.payload?.stock_status || "unknown";
const stockBadge = stock === "instock"
? `<span class="badge stock">En stock</span>`
: `<span class="badge nostock">Sin stock</span>`;
el.innerHTML = `
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge}</div>
<div class="item-meta">
<span class="item-price">${price}</span> ·
SKU: ${sku} ·
ID: ${item.woo_product_id}
</div>
`;
el.onclick = () => {
this.selected = item;
this.renderList();
this.renderDetail();
// Scroll detail panel to top
const detail = this.shadowRoot.getElementById("detail");
if (detail) detail.scrollTop = 0;
};
list.appendChild(el);
}
}
renderDetail() {
const detail = this.shadowRoot.getElementById("detail");
if (!this.selected) {
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
return;
}
const p = this.selected;
const categories = (p.payload?.categories || []).map(c => c.name).join(", ") || "—";
const attributes = (p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
detail.innerHTML = `
<div class="field">
<label>Nombre</label>
<div class="field-value">${p.name || "—"}</div>
</div>
<div class="field">
<label>ID WooCommerce</label>
<div class="field-value">${p.woo_product_id}</div>
</div>
<div class="field">
<label>SKU</label>
<div class="field-value">${p.sku || "—"}</div>
</div>
<div class="field">
<label>Precio</label>
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
</div>
<div class="field">
<label>Categorías</label>
<div class="field-value">${categories}</div>
</div>
<div class="field">
<label>Atributos</label>
<div class="field-value">${attributes}</div>
</div>
<div class="field">
<label>Última actualización</label>
<div class="field-value">${p.refreshed_at ? new Date(p.refreshed_at).toLocaleString() : "—"}</div>
</div>
<div class="field">
<label>Payload completo</label>
<div class="field-value json">${JSON.stringify(p.payload || {}, null, 2)}</div>
</div>
`;
}
}
customElements.define("products-crud", ProductsCrud);

View File

@@ -0,0 +1,320 @@
import { api } from "../lib/api.js";
class RecommendationsCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
this.loading = false;
this.searchQuery = "";
this.editMode = null; // 'create' | 'edit' | null
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
textarea { min-height:60px; resize:vertical; font-size:13px; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
.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:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-key { font-weight:600; color:#e7eef7; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.item-trigger { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
.item-queries { font-size:11px; color:#2ecc71; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; }
.badge.active { background:#0f2a1a; color:#2ecc71; }
.badge.inactive { background:#241214; color:#e74c3c; }
.badge.priority { background:#253245; color:#8aa0b5; }
.form { flex:1; overflow-y:auto; }
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.field-row { display:flex; gap:12px; }
.field-row .field { flex:1; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
.toggle input { width:auto; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Reglas de Recomendacion</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar regla..." style="flex:1" />
<button id="newBtn">+ Nueva</button>
</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="formTitle">Detalle</div>
<div class="form" id="form">
<div class="form-empty">Seleccioná una regla o creá una nueva</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.load(), 300);
};
this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm();
this.load();
}
async load() {
this.loading = true;
this.renderList();
try {
const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
this.items = data.items || [];
this.loading = false;
this.renderList();
} catch (e) {
console.error("Error loading recommendations:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron reglas</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
const trigger = item.trigger || {};
const keywords = (trigger.keywords || []).join(", ") || "—";
const queries = (item.queries || []).slice(0, 3).join(", ");
const hasMore = (item.queries || []).length > 3;
el.innerHTML = `
<div class="item-key">
${item.rule_key}
<span class="badge ${item.active ? "active" : "inactive"}">${item.active ? "Activa" : "Inactiva"}</span>
<span class="badge priority">P: ${item.priority}</span>
</div>
<div class="item-trigger">Keywords: ${keywords}</div>
<div class="item-queries">→ ${queries}${hasMore ? "..." : ""}</div>
`;
el.onclick = () => {
this.selected = item;
this.editMode = "edit";
this.renderList();
this.renderForm();
};
list.appendChild(el);
}
}
showCreateForm() {
this.selected = null;
this.editMode = "create";
this.renderList();
this.renderForm();
}
renderForm() {
const form = this.shadowRoot.getElementById("form");
const title = this.shadowRoot.getElementById("formTitle");
if (!this.editMode) {
title.textContent = "Detalle";
form.innerHTML = `<div class="form-empty">Seleccioná una regla o creá una nueva</div>`;
return;
}
const isCreate = this.editMode === "create";
title.textContent = isCreate ? "Nueva Regla" : "Editar Regla";
const rule_key = this.selected?.rule_key || "";
const trigger = this.selected?.trigger || {};
const queries = this.selected?.queries || [];
const ask_slots = this.selected?.ask_slots || [];
const active = this.selected?.active !== false;
const priority = this.selected?.priority || 100;
// Convert arrays to comma-separated strings for display
const triggerKeywords = (trigger.keywords || []).join(", ");
const queriesText = (queries || []).join(", ");
const askSlotsText = Array.isArray(ask_slots)
? ask_slots.map(s => typeof s === "string" ? s : s?.slot || s?.keyword || "").filter(Boolean).join(", ")
: "";
form.innerHTML = `
<div class="field">
<label>Rule Key (identificador unico)</label>
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_core, alcohol_yes" />
<div class="field-hint">Sin espacios, en minusculas con guiones bajos</div>
</div>
<div class="field-row">
<div class="field">
<label>Prioridad</label>
<input type="number" id="priorityInput" value="${priority}" min="1" max="1000" />
<div class="field-hint">Mayor = primero</div>
</div>
<div class="field">
<label>Estado</label>
<label class="toggle">
<input type="checkbox" id="activeInput" ${active ? "checked" : ""} />
<span>Activa</span>
</label>
</div>
</div>
<div class="field">
<label>Trigger (palabras clave)</label>
<textarea id="triggerInput" placeholder="asado, parrilla, carne, bife...">${triggerKeywords}</textarea>
<div class="field-hint">Palabras que activan esta regla, separadas por coma</div>
</div>
<div class="field">
<label>Productos a recomendar</label>
<textarea id="queriesInput" placeholder="provoleta, chimichurri, ensalada...">${queriesText}</textarea>
<div class="field-hint">Productos a buscar cuando se activa la regla, separados por coma</div>
</div>
<div class="field">
<label>Preguntar sobre... (opcional)</label>
<textarea id="askSlotsInput" placeholder="achuras, cerdo, vino...">${askSlotsText}</textarea>
<div class="field-hint">El bot preguntara al usuario sobre estos temas de forma natural</div>
</div>
<div class="actions">
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
<button id="cancelBtn" class="secondary">Cancelar</button>
</div>
`;
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
if (!isCreate) {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
}
parseCommaSeparated(str) {
return String(str || "")
.split(",")
.map(s => s.trim().toLowerCase())
.filter(Boolean);
}
async save() {
const ruleKey = this.shadowRoot.getElementById("ruleKeyInput").value.trim().toLowerCase().replace(/\s+/g, "_");
const priority = parseInt(this.shadowRoot.getElementById("priorityInput").value, 10) || 100;
const active = this.shadowRoot.getElementById("activeInput").checked;
// Parse comma-separated values into arrays
const triggerKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("triggerInput").value);
const queries = this.parseCommaSeparated(this.shadowRoot.getElementById("queriesInput").value);
const askSlotsKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("askSlotsInput").value);
if (!ruleKey) {
alert("El rule_key es requerido");
return;
}
// Build trigger object with keywords array
const trigger = triggerKeywords.length > 0 ? { keywords: triggerKeywords } : {};
// Ask slots as simple array of keywords (LLM will formulate questions naturally)
const ask_slots = askSlotsKeywords;
const data = {
rule_key: ruleKey,
trigger,
queries,
ask_slots,
active,
priority,
};
try {
if (this.editMode === "create") {
await api.createRecommendation(data);
} else {
await api.updateRecommendation(this.selected.id, data);
}
this.editMode = null;
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error saving recommendation:", e);
alert("Error guardando: " + (e.message || e));
}
}
async delete() {
if (!this.selected?.id) return;
if (!confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`)) return;
try {
await api.deleteRecommendation(this.selected.id);
this.editMode = null;
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error deleting recommendation:", e);
alert("Error eliminando: " + (e.message || e));
}
}
cancel() {
this.editMode = null;
this.selected = null;
this.renderList();
this.renderForm();
}
}
customElements.define("recommendations-crud", RecommendationsCrud);

View File

@@ -10,17 +10,18 @@ class RunTimeline extends HTMLElement {
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
:host { display:block; padding:12px; } :host { display:block; padding:12px; height:100%; overflow:hidden; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; margin-bottom:10px; } .box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
.row { display:flex; gap:8px; align-items:center; } .row { display:flex; gap:8px; align-items:center; }
.muted { color:#8aa0b5; font-size:12px; } .muted { color:#8aa0b5; font-size:12px; }
.title { font-weight:800; } .title { font-weight:800; }
.chatlog { display:flex; flex-direction:column; gap:8px; max-height:520px; overflow:auto; padding-right:6px; margin-top:8px; } .chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
/* WhatsApp-ish dark theme bubbles */ /* WhatsApp-ish dark theme bubbles */
.bubble { max-width:90%; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); } .bubble { max-width:90%; margin-bottom:12px; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); box-sizing:border-box; }
.bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; } .bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
.bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; } .bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
.bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; } .bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; }
.bubble.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
.name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; } .name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; }
.bubble.user .name { color:#cdebd8; text-align:right; } .bubble.user .name { color:#cdebd8; text-align:right; }
.bubble.bot .name { color:#c7d8ee; } .bubble.bot .name { color:#c7d8ee; }
@@ -62,11 +63,30 @@ class RunTimeline extends HTMLElement {
this.loadMessages(); this.loadMessages();
} }
}); });
this._unsubHighlight = on("ui:highlightMessage", ({ message_id }) => {
this.highlightMessage(message_id);
});
// Listen for inspector heights to sync bubble heights
this._unsubInspector = on("ui:inspectorLayout", ({ chat_id, items }) => {
if (!this.chatId || chat_id !== this.chatId) return;
this.applyInspectorHeights(items);
});
// 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;
this.addOptimisticBubble(msg);
});
} }
disconnectedCallback() { disconnectedCallback() {
this._unsubSel?.(); this._unsubSel?.();
this._unsubRun?.(); this._unsubRun?.();
this._unsubHighlight?.();
this._unsubInspector?.();
this._unsubOptimistic?.();
} }
async loadMessages() { async loadMessages() {
@@ -122,6 +142,7 @@ class RunTimeline extends HTMLElement {
const isErr = this.isErrorMsg(m); const isErr = this.isErrorMsg(m);
const bubble = document.createElement("div"); const bubble = document.createElement("div");
bubble.className = `bubble ${isErr ? "err" : who}`; bubble.className = `bubble ${isErr ? "err" : who}`;
bubble.dataset.messageId = m.message_id;
const nameEl = document.createElement("span"); const nameEl = document.createElement("span");
nameEl.className = "name"; nameEl.className = "name";
@@ -146,6 +167,118 @@ class RunTimeline extends HTMLElement {
// auto-scroll // auto-scroll
log.scrollTop = log.scrollHeight; log.scrollTop = log.scrollHeight;
requestAnimationFrame(() => this.emitLayout());
this.bindScroll(log);
}
bindScroll(log) {
if (this._scrollBound) return;
this._scrollBound = true;
log.addEventListener("scroll", () => {
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop });
});
}
emitLayout() {
const log = this.shadowRoot.getElementById("log");
const box = this.shadowRoot.querySelector(".box");
const bubbles = [...log.querySelectorAll(".bubble")];
const items = bubbles.map((el) => {
const styles = window.getComputedStyle(el);
const marginBottom = parseInt(styles.marginBottom || "0", 10) || 0;
return {
message_id: el.dataset.messageId || null,
height: (el.offsetHeight || 0) + marginBottom,
};
});
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");
if (!log) return;
const bubbles = [...log.querySelectorAll(".bubble")];
for (const el of bubbles) {
el.classList.toggle("active", el.dataset.messageId === message_id);
}
// No auto-scroll - mantener posición actual del usuario
}
applyInspectorHeights(items) {
const log = this.shadowRoot.getElementById("log");
if (!log) return;
const BUBBLE_MARGIN = 12;
const MIN_HEIGHT = 120;
const heightMap = new Map((items || []).map((it) => [it.message_id, it.height || 0]));
const bubbles = [...log.querySelectorAll(".bubble")];
for (const el of bubbles) {
const messageId = el.dataset.messageId;
const inspectorHeight = heightMap.get(messageId) || 0;
// Inspector height includes margin, extract content height
const inspectorContentHeight = Math.max(0, inspectorHeight - BUBBLE_MARGIN);
// Use max between inspector height and our minimum
const targetHeight = Math.max(inspectorContentHeight, MIN_HEIGHT);
// Always apply to ensure sync
el.style.minHeight = `${targetHeight}px`;
el.style.marginBottom = `${BUBBLE_MARGIN}px`;
}
}
addOptimisticBubble(msg) {
const log = this.shadowRoot.getElementById("log");
if (!log) return;
// Check if already exists (by optimistic ID pattern)
const existing = log.querySelector(`.bubble[data-message-id^="optimistic-"]`);
if (existing) existing.remove();
const bubble = document.createElement("div");
bubble.className = "bubble user";
bubble.dataset.messageId = msg.message_id;
const nameEl = document.createElement("span");
nameEl.className = "name";
nameEl.textContent = msg.pushName || "test_lucas";
bubble.appendChild(nameEl);
const textEl = document.createElement("div");
textEl.textContent = msg.text || "—";
bubble.appendChild(textEl);
const metaEl = document.createElement("span");
metaEl.className = "meta";
metaEl.textContent = `${new Date(msg.ts).toLocaleString()}${msg.provider} • enviando...`;
bubble.appendChild(metaEl);
log.appendChild(bubble);
log.scrollTop = log.scrollHeight;
// Emit layout update
requestAnimationFrame(() => this.emitLayout());
} }
} }

View File

@@ -0,0 +1,243 @@
import { api } from "../lib/api.js";
import { emit } from "../lib/bus.js";
class UsersCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
this.loading = false;
this.searchQuery = "";
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; padding:16px; overflow:hidden; }
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
input:focus, select:focus { outline:none; border-color:#1f6feb; }
input { flex:1; }
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
button:hover { background:#1a5fd0; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.secondary { background:#253245; }
button.secondary:hover { background:#2d3e52; }
button.danger { background:#e74c3c; }
button.danger:hover { background:#c0392b; }
.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:hover { border-color:#1f6feb; }
.item.active { border-color:#1f6feb; background:#111b2a; }
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
.item-meta { font-size:12px; color:#8aa0b5; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
.badge.woo { background:#0f2a1a; color:#2ecc71; }
.detail { flex:1; overflow-y:auto; }
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.stats { display:flex; gap:16px; margin-bottom:16px; }
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; }
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
</style>
<div class="container">
<div class="panel">
<div class="panel-title">Usuarios</div>
<div class="stats">
<div class="stat">
<div class="stat-value" id="totalCount">—</div>
<div class="stat-label">Total</div>
</div>
<div class="stat">
<div class="stat-value" id="wooCount">—</div>
<div class="stat-label">Con Woo ID</div>
</div>
</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar por chat_id o nombre..." />
</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="detailTitle">Detalle</div>
<div class="detail" id="detail">
<div class="detail-empty">Selecciona un usuario</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("search").oninput = (e) => {
this.searchQuery = e.target.value;
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.load(), 300);
};
this.load();
}
async load() {
this.loading = true;
this.renderList();
try {
const data = await api.users({ q: this.searchQuery, limit: 500 });
this.items = data.items || [];
this.loading = false;
this.renderList();
this.renderStats();
} catch (e) {
console.error("Error loading users:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
renderStats() {
const total = this.items.length;
const withWoo = this.items.filter(u => u.external_customer_id).length;
this.shadowRoot.getElementById("totalCount").textContent = total;
this.shadowRoot.getElementById("wooCount").textContent = withWoo;
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron usuarios</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : "");
const name = item.push_name || item.chat_id.replace(/@.+$/, "");
const wooBadge = item.external_customer_id
? `<span class="badge woo">Woo: ${item.external_customer_id}</span>`
: "";
el.innerHTML = `
<div class="item-name">${name} ${wooBadge}</div>
<div class="item-meta">${item.chat_id}</div>
`;
el.onclick = () => {
this.selected = item;
this.renderList();
this.renderDetail();
};
list.appendChild(el);
}
}
renderDetail() {
const detail = this.shadowRoot.getElementById("detail");
const title = this.shadowRoot.getElementById("detailTitle");
if (!this.selected) {
title.textContent = "Detalle";
detail.innerHTML = `<div class="detail-empty">Selecciona un usuario</div>`;
return;
}
const u = this.selected;
const name = u.push_name || u.chat_id.replace(/@.+$/, "");
title.textContent = name;
detail.innerHTML = `
<div class="field">
<label>Chat ID</label>
<div class="field-value">${u.chat_id}</div>
</div>
<div class="field">
<label>Push Name</label>
<div class="field-value">${u.push_name || "—"}</div>
</div>
<div class="field">
<label>Telefono</label>
<div class="field-value">${u.chat_id.replace(/@.+$/, "")}</div>
</div>
<div class="field">
<label>Woo Customer ID</label>
<div class="field-value">${u.external_customer_id || "Sin vincular"}</div>
</div>
<div class="field">
<label>Provider</label>
<div class="field-value">${u.provider || "—"}</div>
</div>
<div class="field">
<label>Creado</label>
<div class="field-value">${u.created_at ? new Date(u.created_at).toLocaleString() : "—"}</div>
</div>
<div class="field">
<label>Actualizado</label>
<div class="field-value">${u.updated_at ? new Date(u.updated_at).toLocaleString() : "—"}</div>
</div>
<div class="actions">
<button id="openChat">Ver Chat</button>
<button id="deleteConv" class="secondary">Borrar Chat</button>
<button id="deleteUser" class="danger">Borrar Usuario + Woo</button>
</div>
`;
detail.scrollTop = 0;
this.shadowRoot.getElementById("openChat").onclick = () => {
emit("ui:selectedChat", { chat_id: u.chat_id });
emit("ui:switchView", { view: "chat" });
};
this.shadowRoot.getElementById("deleteConv").onclick = async () => {
if (!confirm(`¿Eliminar la conversacion de "${u.chat_id}"?`)) return;
try {
await api.deleteConversation(u.chat_id);
alert("Conversacion eliminada");
} catch (e) {
alert("Error: " + (e.message || e));
}
};
this.shadowRoot.getElementById("deleteUser").onclick = async () => {
if (!confirm(`¿Eliminar usuario "${u.chat_id}", su conversacion y el customer en Woo?`)) return;
try {
await api.deleteUser(u.chat_id, { deleteWoo: true });
this.selected = null;
await this.load();
this.renderDetail();
} catch (e) {
alert("Error: " + (e.message || e));
}
};
}
}
customElements.define("users-crud", UsersCrud);

View File

@@ -57,4 +57,79 @@ export const api = {
if (!chat_id) throw new Error("chat_id_required"); if (!chat_id) throw new Error("chat_id_required");
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json()); return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
}, },
// Products CRUD
async products({ q = "", limit = 2000, offset = 0 } = {}) {
const u = new URL("/products", location.origin);
if (q) u.searchParams.set("q", q);
u.searchParams.set("limit", String(limit));
u.searchParams.set("offset", String(offset));
return fetch(u).then(r => r.json());
},
async productById(id) {
if (!id) return null;
return fetch(`/products/${encodeURIComponent(id)}`).then(r => r.json());
},
async syncProducts() {
return fetch("/products/sync", { method: "POST" }).then(r => r.json());
},
// Aliases CRUD
async aliases({ q = "", woo_product_id = null, limit = 200 } = {}) {
const u = new URL("/aliases", location.origin);
if (q) u.searchParams.set("q", q);
if (woo_product_id) u.searchParams.set("woo_product_id", String(woo_product_id));
u.searchParams.set("limit", String(limit));
return fetch(u).then(r => r.json());
},
async createAlias(data) {
return fetch("/aliases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then(r => r.json());
},
async updateAlias(alias, data) {
return fetch(`/aliases/${encodeURIComponent(alias)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then(r => r.json());
},
async deleteAlias(alias) {
return fetch(`/aliases/${encodeURIComponent(alias)}`, { method: "DELETE" }).then(r => r.json());
},
// Recommendations CRUD
async recommendations({ q = "", limit = 200 } = {}) {
const u = new URL("/recommendations", location.origin);
if (q) u.searchParams.set("q", q);
u.searchParams.set("limit", String(limit));
return fetch(u).then(r => r.json());
},
async createRecommendation(data) {
return fetch("/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then(r => r.json());
},
async updateRecommendation(id, data) {
return fetch(`/recommendations/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then(r => r.json());
},
async deleteRecommendation(id) {
return fetch(`/recommendations/${encodeURIComponent(id)}`, { method: "DELETE" }).then(r => r.json());
},
}; };

View File

@@ -1,3 +1,4 @@
import "dotenv/config";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/sync";
@@ -62,7 +63,7 @@ function extractAttributes(row) {
} }
function normalizeRow(row) { function normalizeRow(row) {
const wooId = Number(row["ID"] || row["Id"] || row["id"] || null); const wooId = Number(row["ID"] || row["ID"] || row["\uFEFFID"] || row["Id"] || row["id"] || null);
const type = String(row["Tipo"] || "").trim().toLowerCase(); const type = String(row["Tipo"] || "").trim().toLowerCase();
const parentId = Number(row["Superior"] || null) || null; const parentId = Number(row["Superior"] || null) || null;
const name = String(row["Nombre"] || "").trim(); const name = String(row["Nombre"] || "").trim();
@@ -194,7 +195,13 @@ async function main() {
const { file, tenantKey, replace } = parseArgs(); const { file, tenantKey, replace } = parseArgs();
const abs = path.resolve(file); const abs = path.resolve(file);
const content = fs.readFileSync(abs); const content = fs.readFileSync(abs);
const records = parse(content, { columns: true, skip_empty_lines: true }); const records = parse(content, {
columns: true,
skip_empty_lines: true,
relax_column_count: true,
relax_column_count_less: true,
relax_column_count_more: true,
});
const normalized = records.map((r) => ({ item: normalizeRow(r), raw: r })).filter((r) => r.item.woo_id && r.item.name); const normalized = records.map((r) => ({ item: normalizeRow(r), raw: r })).filter((r) => r.item.woo_id && r.item.name);
const tenants = await getTenants(tenantKey); const tenants = await getTenants(tenantKey);

View File

@@ -0,0 +1,68 @@
import { handleListAliases, handleCreateAlias, handleUpdateAlias, handleDeleteAlias } from "../handlers/aliases.js";
export const makeListAliases = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const woo_product_id = req.query.woo_product_id ? parseInt(req.query.woo_product_id, 10) : null;
const limit = req.query.limit || "200";
const result = await handleListAliases({ tenantId, q, woo_product_id, limit });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { alias, woo_product_id, boost, category_hint, metadata } = req.body || {};
if (!alias || !woo_product_id) {
return res.status(400).json({ ok: false, error: "alias_and_woo_product_id_required" });
}
const result = await handleCreateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
if (err.code === "23505") { // unique violation
return res.status(409).json({ ok: false, error: "alias_already_exists" });
}
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeUpdateAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const alias = req.params.alias;
const { woo_product_id, boost, category_hint, metadata } = req.body || {};
if (!woo_product_id) {
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
}
const result = await handleUpdateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
if (!result) {
return res.status(404).json({ ok: false, error: "alias_not_found" });
}
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeDeleteAlias = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const alias = req.params.alias;
const result = await handleDeleteAlias({ tenantId, alias });
res.json({ ok: true, ...result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -1,4 +1,4 @@
import { handleSearchProducts } from "../handlers/products.js"; import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts } from "../handlers/products.js";
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => { export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
try { try {
@@ -14,3 +14,43 @@ export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
} }
}; };
export const makeListProducts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const limit = req.query.limit || "2000";
const offset = req.query.offset || "0";
const result = await handleListProducts({ tenantId, q, limit, offset });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeGetProduct = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const wooProductId = req.params.id;
const result = await handleGetProduct({ tenantId, wooProductId });
if (!result) {
return res.status(404).json({ ok: false, error: "product_not_found" });
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleSyncProducts({ tenantId });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -0,0 +1,88 @@
import {
handleListRecommendations,
handleGetRecommendation,
handleCreateRecommendation,
handleUpdateRecommendation,
handleDeleteRecommendation
} from "../handlers/recommendations.js";
export const makeListRecommendations = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const q = req.query.q || "";
const limit = req.query.limit || "200";
const result = await handleListRecommendations({ tenantId, q, limit });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const result = await handleGetRecommendation({ tenantId, id });
if (!result) {
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { rule_key, trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
if (!rule_key) {
return res.status(400).json({ ok: false, error: "rule_key_required" });
}
const result = await handleCreateRecommendation({
tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority
});
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
if (err.code === "23505") { // unique violation
return res.status(409).json({ ok: false, error: "rule_key_already_exists" });
}
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeUpdateRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const { trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
const result = await handleUpdateRecommendation({
tenantId, id, trigger, queries, boosts, ask_slots, active, priority
});
if (!result) {
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
}
res.json({ ok: true, item: result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeDeleteRecommendation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = req.params.id;
const result = await handleDeleteRecommendation({ tenantId, id });
res.json({ ok: true, ...result });
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

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

@@ -0,0 +1,296 @@
import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Products
// ─────────────────────────────────────────────────────────────
export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
const lim = Math.max(1, Math.min(5000, parseInt(limit, 10) || 2000));
const off = Math.max(0, parseInt(offset, 10) || 0);
const query = String(q || "").trim();
let sql, params;
if (query) {
const like = `%${query}%`;
sql = `
select
woo_id as woo_product_id,
name,
slug as sku,
price_current as price,
stock_status,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload
from woo_products_snapshot
where tenant_id = $1
and (name ilike $2 or coalesce(slug,'') ilike $2)
order by name asc
limit $3 offset $4
`;
params = [tenantId, like, lim, off];
} else {
sql = `
select
woo_id as woo_product_id,
name,
slug as sku,
price_current as price,
stock_status,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload
from woo_products_snapshot
where tenant_id = $1
order by name asc
limit $2 offset $3
`;
params = [tenantId, lim, off];
}
const { rows } = await pool.query(sql, params);
return rows;
}
export async function getProductByWooId({ tenantId, wooProductId }) {
const sql = `
select
woo_id as woo_product_id,
name,
slug as sku,
price_current as price,
stock_status,
categories,
attributes_normalized,
updated_at as refreshed_at,
raw as payload
from woo_products_snapshot
where tenant_id = $1 and woo_id = $2
limit 1
`;
const { rows } = await pool.query(sql, [tenantId, wooProductId]);
return rows[0] || null;
}
// ─────────────────────────────────────────────────────────────
// Aliases
// ─────────────────────────────────────────────────────────────
function normalizeAlias(alias) {
return String(alias || "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
export async function listAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
const query = String(q || "").trim();
let sql, params;
if (woo_product_id) {
sql = `
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
from product_aliases
where tenant_id = $1 and woo_product_id = $2
order by alias asc
limit $3
`;
params = [tenantId, woo_product_id, lim];
} else if (query) {
const like = `%${query}%`;
sql = `
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
from product_aliases
where tenant_id = $1 and (alias ilike $2 or normalized_alias ilike $2)
order by alias asc
limit $3
`;
params = [tenantId, like, lim];
} else {
sql = `
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
from product_aliases
where tenant_id = $1
order by alias asc
limit $2
`;
params = [tenantId, lim];
}
const { rows } = await pool.query(sql, params);
return rows;
}
export async function insertAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
const normalizedAlias = normalizeAlias(alias);
const sql = `
insert into product_aliases (tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata)
values ($1, $2, $3, $4, $5, $6, $7)
returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
alias.toLowerCase().trim(),
normalizedAlias,
woo_product_id,
category_hint,
boost || 0,
JSON.stringify(metadata || {}),
]);
return rows[0];
}
export async function updateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
const normalizedAlias = normalizeAlias(alias);
const sql = `
update product_aliases
set woo_product_id = $3, category_hint = $4, boost = $5, metadata = $6, normalized_alias = $7, updated_at = now()
where tenant_id = $1 and alias = $2
returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
alias.toLowerCase().trim(),
woo_product_id,
category_hint,
boost || 0,
JSON.stringify(metadata || {}),
normalizedAlias,
]);
return rows[0] || null;
}
export async function deleteAlias({ tenantId, alias }) {
const sql = `delete from product_aliases where tenant_id = $1 and alias = $2 returning alias`;
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim()]);
return rows.length > 0;
}
// ─────────────────────────────────────────────────────────────
// Recommendations
// ─────────────────────────────────────────────────────────────
export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
const query = String(q || "").trim();
let sql, params;
if (query) {
const like = `%${query}%`;
sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id = $1 and rule_key ilike $2
order by priority desc, rule_key asc
limit $3
`;
params = [tenantId, like, lim];
} else {
sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id = $1
order by priority desc, rule_key asc
limit $2
`;
params = [tenantId, lim];
}
const { rows } = await pool.query(sql, params);
return rows;
}
export async function getRecommendationById({ tenantId, id }) {
const sql = `
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id = $1 and id = $2
limit 1
`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows[0] || null;
}
export async function insertRecommendation({
tenantId,
rule_key,
trigger = {},
queries = [],
boosts = {},
ask_slots = [],
active = true,
priority = 100,
}) {
const sql = `
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
values ($1, $2, $3, $4, $5, $6, $7, $8)
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
rule_key.toLowerCase().trim(),
JSON.stringify(trigger || {}),
JSON.stringify(queries || []),
JSON.stringify(boosts || {}),
JSON.stringify(ask_slots || []),
active !== false,
priority || 100,
]);
return rows[0];
}
export async function updateRecommendation({
tenantId,
id,
trigger,
queries,
boosts,
ask_slots,
active,
priority,
}) {
const sql = `
update product_reco_rules
set
trigger = $3,
queries = $4,
boosts = $5,
ask_slots = $6,
active = $7,
priority = $8,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
`;
const { rows } = await pool.query(sql, [
tenantId,
id,
JSON.stringify(trigger || {}),
JSON.stringify(queries || []),
JSON.stringify(boosts || {}),
JSON.stringify(ask_slots || []),
active !== false,
priority || 100,
]);
return rows[0] || null;
}
export async function deleteRecommendation({ tenantId, id }) {
const sql = `delete from product_reco_rules where tenant_id = $1 and id = $2 returning id`;
const { rows } = await pool.query(sql, [tenantId, id]);
return rows.length > 0;
}

View File

@@ -0,0 +1,19 @@
import { listAliases, insertAlias, updateAlias, deleteAlias } from "../db/repo.js";
export async function handleListAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
const items = await listAliases({ tenantId, q, woo_product_id, limit });
return { items };
}
export async function handleCreateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
return insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
}
export async function handleUpdateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
return updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
}
export async function handleDeleteAlias({ tenantId, alias }) {
const deleted = await deleteAlias({ tenantId, alias });
return { deleted };
}

View File

@@ -1,4 +1,5 @@
import { searchSnapshotItems } from "../../shared/wooSnapshot.js"; import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
import { listProducts, getProductByWooId } from "../db/repo.js";
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) { export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
const { items, source } = await searchSnapshotItems({ const { items, source } = await searchSnapshotItems({
@@ -9,3 +10,18 @@ export async function handleSearchProducts({ tenantId, q = "", limit = "10", for
return { items, source }; return { items, source };
} }
export async function handleListProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
const items = await listProducts({ tenantId, q, limit, offset });
return { items };
}
export async function handleGetProduct({ tenantId, wooProductId }) {
return getProductByWooId({ tenantId, wooProductId });
}
export async function handleSyncProducts({ tenantId }) {
// This is a placeholder - actual sync would fetch from Woo API
// For now, just return success
return { ok: true, message: "Sync triggered (use import script for full sync)" };
}

View File

@@ -0,0 +1,47 @@
import {
listRecommendations,
getRecommendationById,
insertRecommendation,
updateRecommendation,
deleteRecommendation,
} from "../db/repo.js";
export async function handleListRecommendations({ tenantId, q = "", limit = 200 }) {
const items = await listRecommendations({ tenantId, q, limit });
return { items };
}
export async function handleGetRecommendation({ tenantId, id }) {
return getRecommendationById({ tenantId, id });
}
export async function handleCreateRecommendation({
tenantId,
rule_key,
trigger = {},
queries = [],
boosts = {},
ask_slots = [],
active = true,
priority = 100,
}) {
return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority });
}
export async function handleUpdateRecommendation({
tenantId,
id,
trigger,
queries,
boosts,
ask_slots,
active,
priority,
}) {
return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority });
}
export async function handleDeleteRecommendation({ tenantId, id }) {
const deleted = await deleteRecommendation({ tenantId, id });
return { deleted };
}

View File

@@ -6,6 +6,26 @@ import { debug as dbg } from "../../shared/debug.js";
export async function handleEvolutionWebhook(body) { export async function handleEvolutionWebhook(body) {
const t0 = Date.now(); const t0 = Date.now();
const parsed = parseEvolutionWebhook(body); 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 } }; return { status: 200, payload: { ok: true, ignored: parsed.reason } };
} }

View File

@@ -6,7 +6,9 @@ import { makeListRuns, makeGetRunById } from "../../0-ui/controllers/runs.js";
import { makeSimSend } from "../controllers/sim.js"; import { makeSimSend } from "../controllers/sim.js";
import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js"; import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js";
import { makeListMessages } from "../../0-ui/controllers/messages.js"; import { makeListMessages } from "../../0-ui/controllers/messages.js";
import { makeSearchProducts } from "../../0-ui/controllers/products.js"; import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts } from "../../0-ui/controllers/products.js";
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
function nowIso() { function nowIso() {
@@ -49,7 +51,22 @@ export function createSimulatorRouter({ tenantId }) {
router.delete("/conversations/:chat_id", makeDeleteConversation(getTenantId)); router.delete("/conversations/:chat_id", makeDeleteConversation(getTenantId));
router.post("/conversations/:chat_id/retry-last", makeRetryLast(getTenantId)); router.post("/conversations/:chat_id/retry-last", makeRetryLast(getTenantId));
router.get("/messages", makeListMessages(getTenantId)); router.get("/messages", makeListMessages(getTenantId));
router.get("/products", makeSearchProducts(getTenantId)); router.get("/products", makeListProducts(getTenantId));
router.get("/products/search", makeSearchProducts(getTenantId));
router.get("/products/:id", makeGetProduct(getTenantId));
router.post("/products/sync", makeSyncProducts(getTenantId));
router.get("/aliases", makeListAliases(getTenantId));
router.post("/aliases", makeCreateAlias(getTenantId));
router.put("/aliases/:alias", makeUpdateAlias(getTenantId));
router.delete("/aliases/:alias", makeDeleteAlias(getTenantId));
router.get("/recommendations", makeListRecommendations(getTenantId));
router.get("/recommendations/:id", makeGetRecommendation(getTenantId));
router.post("/recommendations", makeCreateRecommendation(getTenantId));
router.put("/recommendations/:id", makeUpdateRecommendation(getTenantId));
router.delete("/recommendations/:id", makeDeleteRecommendation(getTenantId));
router.get("/users", makeListUsers(getTenantId)); router.get("/users", makeListUsers(getTenantId));
router.delete("/users/:chat_id", makeDeleteUser(getTenantId)); router.delete("/users/:chat_id", makeDeleteUser(getTenantId));

View File

@@ -65,7 +65,7 @@ export async function touchConversationState({ tenant_id, wa_chat_id }) {
on conflict (tenant_id, wa_chat_id) on conflict (tenant_id, wa_chat_id)
do update set do update set
updated_at = now() updated_at = now()
returning tenant_id, wa_chat_id, state, last_intent, context, updated_at returning tenant_id, wa_chat_id, state, last_intent, last_order_id, context, state_updated_at, updated_at, created_at
`; `;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]); const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
return rows[0] || null; return rows[0] || null;
@@ -272,10 +272,16 @@ export async function getRunById({ tenant_id, run_id }) {
export async function getRecentMessagesForLLM({ export async function getRecentMessagesForLLM({
tenant_id, tenant_id,
wa_chat_id, wa_chat_id,
limit = 20,
maxCharsPerMessage = 800,
}) { }) {
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 20)); const limRaw = parseInt(process.env.LIMIT_CONVERSATIONS || "", 10);
const maxCharsRaw = parseInt(process.env.MAX_CHARS_PER_MESSAGE || "", 10);
if (!Number.isFinite(limRaw) || limRaw <= 0) {
throw new Error("LIMIT_CONVERSATIONS env is required and must be a positive integer");
}
if (!Number.isFinite(maxCharsRaw) || maxCharsRaw <= 0) {
throw new Error("MAX_CHARS_PER_MESSAGE env is required and must be a positive integer");
}
const lim = Math.max(1, Math.min(50, limRaw));
const q = ` const q = `
select direction, ts, text select direction, ts, text
from wa_messages from wa_messages
@@ -290,7 +296,7 @@ export async function getRecentMessagesForLLM({
return rows.reverse().map((r) => ({ return rows.reverse().map((r) => ({
role: r.direction === "in" ? "user" : "assistant", role: r.direction === "in" ? "user" : "assistant",
content: String(r.text).trim().slice(0, maxCharsPerMessage), content: String(r.text).trim().slice(0, maxCharsRaw),
})); }));
} }
@@ -557,6 +563,28 @@ export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
})); }));
} }
export async function getRecoRules({ tenant_id }) {
const sql = `
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id=$1 and active=true
order by priority asc, id asc
`;
const { rows } = await pool.query(sql, [tenant_id]);
return rows;
}
export async function getRecoRuleByKey({ tenant_id, rule_key }) {
const sql = `
select id, tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
from product_reco_rules
where tenant_id=$1 and rule_key=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, rule_key]);
return rows[0] || null;
}
export async function getProductEmbedding({ tenant_id, content_hash }) { export async function getProductEmbedding({ tenant_id, content_hash }) {
const sql = ` const sql = `
select tenant_id, content_hash, content_text, embedding, model, updated_at select tenant_id, content_hash, content_text, embedding, model, updated_at

View File

@@ -1,6 +1,5 @@
import crypto from "crypto"; import crypto from "crypto";
import { import {
getConversationState,
insertMessage, insertMessage,
insertRun, insertRun,
touchConversationState, touchConversationState,
@@ -124,17 +123,56 @@ export async function processMessage({
meta = null, meta = null,
}) { }) {
const { started_at, mark, msBetween } = makePerf(); const { started_at, mark, msBetween } = makePerf();
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H2",
location: "pipeline.js:128",
message: "processMessage_enter",
data: {
tenantId: tenantId || null,
provider,
chat_id: chat_id || null,
text_len: String(text || "").length,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id }); const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
mark("start"); mark("start");
const stageDebug = dbg.perf; const stageDebug = dbg.perf;
const prev = await getConversationState(tenantId, chat_id); mark("after_touchConversationState");
mark("after_getConversationState");
const isStale = const isStale =
prev?.state_updated_at && prev?.state_updated_at &&
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000;
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE"; 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, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
@@ -158,7 +196,6 @@ export async function processMessage({
const history = await getRecentMessagesForLLM({ const history = await getRecentMessagesForLLM({
tenant_id: tenantId, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
limit: 20,
}); });
const conversation_history = collapseAssistantMessages(history); const conversation_history = collapseAssistantMessages(history);
mark("after_getRecentMessagesForLLM_for_plan"); mark("after_getRecentMessagesForLLM_for_plan");
@@ -185,6 +222,26 @@ export async function processMessage({
llmMeta = { kind: "nlu_v3", audit: decision.audit || null }; llmMeta = { kind: "nlu_v3", audit: decision.audit || null };
tools = []; tools = [];
mark("after_turn_v3"); 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 isSimulated = provider === "sim" || meta?.source === "sim";
@@ -397,8 +454,8 @@ export async function processMessage({
run_id, run_id,
end_to_end_ms, end_to_end_ms,
ms: { ms: {
db_state_ms: msBetween("start", "after_getConversationState"), db_state_ms: msBetween("start", "after_touchConversationState"),
db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"), db_identity_ms: msBetween("after_touchConversationState", "after_getExternalCustomerIdByChat"),
insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"), insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"), history_for_plan_ms: msBetween("after_insertMessage_in", "after_getRecentMessagesForLLM_for_plan"),
insert_run_ms: msBetween("before_insertRun", "after_insertRun"), insert_run_ms: msBetween("before_insertRun", "after_insertRun"),

View File

@@ -154,6 +154,26 @@ export async function retrieveCandidates({
limit: lim, limit: lim,
}); });
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 }; 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) => { let candidates = (wooItems || []).map((c) => {
const lit = literalScore(q, c); const lit = literalScore(q, c);

View File

@@ -75,14 +75,14 @@ const NluV3JsonSchema = {
properties: { properties: {
intent: { intent: {
type: "string", type: "string",
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "other"], enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "greeting", "recommend", "other"],
}, },
confidence: { type: "number", minimum: 0, maximum: 1 }, confidence: { type: "number", minimum: 0, maximum: 1 },
language: { type: "string" }, language: { type: "string" },
entities: { entities: {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation"], required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation", "items"],
properties: { properties: {
product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, product_query: { anyOf: [{ type: "string" }, { type: "null" }] },
quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
@@ -103,6 +103,25 @@ const NluV3JsonSchema = {
}, },
attributes: { type: "array", items: { type: "string" } }, attributes: { type: "array", items: { type: "string" } },
preparation: { type: "array", items: { type: "string" } }, preparation: { type: "array", items: { type: "string" } },
// Soporte para múltiples productos en un mensaje
items: {
anyOf: [
{ type: "null" },
{
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["product_query"],
properties: {
product_query: { type: "string", minLength: 1 },
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] },
},
},
},
],
},
}, },
}, },
needs: { needs: {
@@ -120,6 +139,148 @@ const NluV3JsonSchema = {
const ajv = new Ajv({ allErrors: true, strict: true }); const ajv = new Ajv({ allErrors: true, strict: true });
const validateNluV3 = ajv.compile(NluV3JsonSchema); const validateNluV3 = ajv.compile(NluV3JsonSchema);
const RecommendWriterSchema = {
$id: "RecommendWriter",
type: "object",
additionalProperties: false,
required: ["reply"],
properties: {
reply: { type: "string", minLength: 1 },
suggested_actions: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["type"],
properties: {
type: { type: "string", enum: ["add_to_cart"] },
product_id: { anyOf: [{ type: "number" }, { type: "null" }] },
quantity: { anyOf: [{ type: "number" }, { type: "null" }] },
unit: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
},
},
};
const validateRecommendWriter = ajv.compile(RecommendWriterSchema);
function normalizeUnitValue(unit) {
if (!unit) return null;
const u = String(unit).trim().toLowerCase();
if (["kg", "kilo", "kilos", "kgs", "kg.", "kilogramo", "kilogramos"].includes(u)) return "kg";
if (["g", "gr", "gr.", "gramo", "gramos"].includes(u)) return "g";
if (["unidad", "unidades", "unit", "u"].includes(u)) return "unidad";
return null;
}
function inferSelectionFromText(text) {
const t = String(text || "").toLowerCase();
const m = /\b(\d{1,2})\b/.exec(t);
if (m) return { type: "index", value: String(m[1]) };
if (/\bprimera\b|\bprimero\b/.test(t)) return { type: "index", value: "1" };
if (/\bsegunda\b|\bsegundo\b/.test(t)) return { type: "index", value: "2" };
if (/\btercera\b|\btercero\b/.test(t)) return { type: "index", value: "3" };
if (/\bcuarta\b|\bcuarto\b/.test(t)) return { type: "index", value: "4" };
if (/\bquinta\b|\bquinto\b/.test(t)) return { type: "index", value: "5" };
if (/\bsexta\b|\bsexto\b/.test(t)) return { type: "index", value: "6" };
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return { type: "index", value: "7" };
if (/\boctava\b|\boctavo\b/.test(t)) return { type: "index", value: "8" };
if (/\bnovena\b|\bnoveno\b/.test(t)) return { type: "index", value: "9" };
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return { type: "index", value: "10" };
return null;
}
function normalizeNluOutput(parsed, input) {
const base = nluV3Fallback();
const out = { ...base, ...(parsed && typeof parsed === "object" ? parsed : {}) };
if (parsed && typeof parsed === "object") {
if (typeof parsed["needs.catalog_lookup"] === "boolean") {
out.needs = { ...(out.needs || {}), catalog_lookup: parsed["needs.catalog_lookup"] };
}
if (typeof parsed["needs.knowledge_lookup"] === "boolean") {
out.needs = { ...(out.needs || {}), knowledge_lookup: parsed["needs.knowledge_lookup"] };
}
}
out.intent = NluV3JsonSchema.properties.intent.enum.includes(out.intent) ? out.intent : "other";
out.confidence = Number.isFinite(Number(out.confidence)) ? Number(out.confidence) : 0;
out.language = typeof out.language === "string" && out.language ? out.language : "es-AR";
const entities = out.entities && typeof out.entities === "object" ? out.entities : {};
// Normalizar items si existe
let normalizedItems = null;
if (Array.isArray(entities.items) && entities.items.length > 0) {
normalizedItems = entities.items
.filter((item) => item && typeof item === "object" && item.product_query)
.map((item) => ({
product_query: String(item.product_query || "").trim(),
quantity: Number.isFinite(Number(item.quantity)) ? Number(item.quantity) : null,
unit: normalizeUnitValue(item.unit),
}))
.filter((item) => item.product_query.length > 0);
if (normalizedItems.length === 0) normalizedItems = null;
}
out.entities = {
product_query: entities.product_query ?? null,
quantity: Number.isFinite(Number(entities.quantity)) ? Number(entities.quantity) : entities.quantity ?? null,
unit: normalizeUnitValue(entities.unit),
selection: entities.selection ?? null,
attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
items: normalizedItems,
};
const hasPendingItem = Boolean(input?.pending_context?.pending_item);
const hasShownOptions = Array.isArray(input?.last_shown_options) && input.last_shown_options.length > 0;
// Solo permitir selection si hay opciones mostradas o pending_clarification
if (hasPendingItem || !hasShownOptions) {
out.entities.selection = null;
}
if (out.entities.selection && typeof out.entities.selection === "object") {
const sel = out.entities.selection;
const valueOk = typeof sel.value === "string" && sel.value.trim().length > 0;
const typeOk = typeof sel.type === "string" && ["index", "text", "sku"].includes(sel.type);
if (!valueOk || !typeOk) {
// Solo inferir selección si hay opciones mostradas y no hay pending_item
const canInfer = hasShownOptions && !hasPendingItem;
const inferred = canInfer ? inferSelectionFromText(input?.last_user_message) : null;
out.entities.selection = inferred || null;
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H11",
location: "openai.js:129",
message: "selection_inferred",
data: {
inferred: Boolean(inferred),
pending_item: hasPendingItem,
has_shown_options: hasShownOptions,
text: String(input?.last_user_message || "").slice(0, 20),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
}
out.needs = {
catalog_lookup: Boolean(out.needs?.catalog_lookup),
knowledge_lookup: Boolean(out.needs?.knowledge_lookup),
};
return out;
}
function nluV3Fallback() { function nluV3Fallback() {
return { return {
intent: "other", intent: "other",
@@ -132,6 +293,7 @@ function nluV3Fallback() {
selection: null, selection: null,
attributes: [], attributes: [],
preparation: [], preparation: [],
items: null,
}, },
needs: { catalog_lookup: false, knowledge_lookup: false }, needs: { catalog_lookup: false, knowledge_lookup: false },
}; };
@@ -154,19 +316,86 @@ export async function llmNluV3({ input, model } = {}) {
"IMPORTANTE:\n" + "IMPORTANTE:\n" +
"- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" + "- NO decidas estados (FSM), NO planifiques acciones, NO inventes productos ni precios.\n" +
"- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" + "- Respondé SOLO con JSON válido, EXACTAMENTE con las keys del contrato. additionalProperties=false.\n" +
"- Si hay opciones mostradas y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" + "- Incluí SIEMPRE TODAS las keys requeridas, aunque el valor sea null/[]/false.\n" +
"- Si hay opciones mostradas (last_shown_options no vacío) y el usuario responde con un número/ordinal ('el segundo'), eso es entities.selection {type:'index'}.\n" +
"- IMPORTANTE: Si NO hay opciones mostradas (last_shown_options vacío) y el usuario dice un número + producto (ej: '2 provoletas'), eso es quantity+product_query, NO selection.\n" +
"- selection SOLO aplica cuando hay opciones visibles para seleccionar (last_shown_options tiene elementos).\n" +
"- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" + "- Si el usuario responde 'mostrame más', poné intent='browse' y entities.selection=null (la paginación la maneja el servidor).\n" +
"- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n"; "- needs.catalog_lookup debe ser true para intents price_query|browse|add_to_cart si NO es una pura selección sobre opciones ya mostradas.\n" +
"- Si el usuario pide recomendación/sugerencias, usá intent='recommend' y needs.catalog_lookup=true.\n" +
"- MULTI-ITEMS: Si el usuario menciona MÚLTIPLES productos en un mensaje (ej: '1 chimichurri y 2 provoletas'), usá entities.items con array de objetos.\n" +
" Ejemplo: items:[{product_query:'chimichurri',quantity:1,unit:null},{product_query:'provoletas',quantity:2,unit:null}]\n" +
" En este caso, product_query/quantity/unit del nivel superior quedan null.\n" +
"- Si es UN SOLO producto, usá product_query/quantity/unit normalmente e items=null.\n" +
"FORMATO JSON ESTRICTO (ejemplo, usá null/[]/false si no hay datos):\n" +
"{\n" +
" \"intent\":\"other\",\n" +
" \"confidence\":0,\n" +
" \"language\":\"es-AR\",\n" +
" \"entities\":{\n" +
" \"product_query\":null,\n" +
" \"quantity\":null,\n" +
" \"unit\":null,\n" +
" \"selection\":null,\n" +
" \"attributes\":[],\n" +
" \"preparation\":[],\n" +
" \"items\":null\n" +
" },\n" +
" \"needs\":{\n" +
" \"catalog_lookup\":false,\n" +
" \"knowledge_lookup\":false\n" +
" }\n" +
"}\n";
const user = JSON.stringify(input ?? {}); const user = JSON.stringify(input ?? {});
// intento 1 // intento 1
const first = await jsonCompletion({ system: systemBase, user, model }); const first = await jsonCompletion({ system: systemBase, user, model });
if (validateNluV3(first.parsed)) { const firstNormalized = normalizeNluOutput(first.parsed, input);
return { nlu: first.parsed, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } }; // #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H10",
location: "openai.js:196",
message: "nlu_normalized_first",
data: {
intent: firstNormalized?.intent || null,
unit: firstNormalized?.entities?.unit || null,
selection: firstNormalized?.entities?.selection ? "set" : "null",
needs_catalog: Boolean(firstNormalized?.needs?.catalog_lookup),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (validateNluV3(firstNormalized)) {
return { nlu: firstNormalized, raw_text: first.raw_text, model: first.model, usage: first.usage, schema: "v3", validation: { ok: true } };
} }
const errors1 = nluV3Errors(); 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 = const systemRetry =
@@ -176,10 +405,50 @@ export async function llmNluV3({ input, model } = {}) {
try { try {
const second = await jsonCompletion({ system: systemRetry, user, model }); const second = await jsonCompletion({ system: systemRetry, user, model });
if (validateNluV3(second.parsed)) { const secondNormalized = normalizeNluOutput(second.parsed, input);
return { nlu: second.parsed, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } }; // #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H10",
location: "openai.js:242",
message: "nlu_normalized_retry",
data: {
intent: secondNormalized?.intent || null,
unit: secondNormalized?.entities?.unit || null,
selection: secondNormalized?.entities?.selection ? "set" : "null",
needs_catalog: Boolean(secondNormalized?.needs?.catalog_lookup),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (validateNluV3(secondNormalized)) {
return { nlu: secondNormalized, raw_text: second.raw_text, model: second.model, usage: second.usage, schema: "v3", validation: { ok: true, retried: true } };
} }
const errors2 = nluV3Errors(); 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(), nlu: nluV3Fallback(),
raw_text: second.raw_text, raw_text: second.raw_text,
@@ -200,4 +469,53 @@ export async function llmNluV3({ input, model } = {}) {
} }
} }
export async function llmRecommendWriter({
base_item,
slots = {},
candidates = [],
locale = "es-AR",
model,
} = {}) {
const system =
"Sos un redactor de recomendaciones (es-AR). Solo podés usar productos de la lista.\n" +
"NO inventes productos ni precios. Devolvé SOLO JSON con este formato:\n" +
"{\n" +
" \"reply\": \"texto final\",\n" +
" \"suggested_actions\": [\n" +
" {\"type\":\"add_to_cart\",\"product_id\":123,\"quantity\":null,\"unit\":null}\n" +
" ]\n" +
"}\n" +
"Si no sugerís acciones, usá suggested_actions: [].\n";
const user = JSON.stringify({
locale,
base_item,
slots,
candidates: candidates.map((c) => ({
woo_product_id: c?.woo_product_id || null,
name: c?.name || null,
price: c?.price ?? null,
categories: c?.categories || [],
})),
});
const first = await jsonCompletion({ system, user, model });
if (validateRecommendWriter(first.parsed)) {
return {
reply: first.parsed.reply,
suggested_actions: first.parsed.suggested_actions || [],
raw_text: first.raw_text,
model: first.model,
usage: first.usage,
validation: { ok: true },
};
}
return {
reply: null,
suggested_actions: [],
raw_text: first.raw_text,
model: first.model,
usage: first.usage,
validation: { ok: false, errors: validateRecommendWriter.errors || [] },
};
}
// Legacy llmPlan/llmExtract y NLU v2 removidos. // Legacy llmPlan/llmExtract y NLU v2 removidos.

View File

@@ -0,0 +1,217 @@
import { getRecoRules } from "../2-identity/db/repo.js";
import { retrieveCandidates } from "./catalogRetrieval.js";
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
import { llmRecommendWriter } from "./openai.js";
function normalizeText(s) {
return String(s || "")
.toLowerCase()
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parseYesNo(text) {
const t = normalizeText(text);
if (!t) return null;
if (/\b(si|sí|sisi|dale|ok|claro|obvio|tomo)\b/.test(t)) return true;
if (/\b(no|nop|nunca|nope|sin alcohol)\b/.test(t)) return false;
return null;
}
function pickBaseItem({ prev_context, basket_items }) {
const pending = prev_context?.pending_item;
if (pending?.name) {
return {
product_id: pending.product_id || null,
name: pending.name,
label: pending.name,
categories: pending.categories || [],
};
}
const items = Array.isArray(basket_items) ? basket_items : [];
const last = items[items.length - 1];
if (!last) return null;
return {
product_id: last.product_id || null,
name: last.label || last.name || "ese producto",
label: last.label || last.name || "ese producto",
categories: last.categories || [],
};
}
function ruleMatchesBase({ rule, base_item, slots }) {
const trigger = rule?.trigger && typeof rule.trigger === "object" ? rule.trigger : {};
const text = normalizeText(base_item?.name || base_item?.label || "");
const categories = Array.isArray(base_item?.categories) ? base_item.categories.map((c) => normalizeText(c?.name || c)) : [];
const keywords = Array.isArray(trigger.keywords) ? trigger.keywords.map(normalizeText).filter(Boolean) : [];
const cats = Array.isArray(trigger.categories) ? trigger.categories.map(normalizeText).filter(Boolean) : [];
const always = Boolean(trigger.always);
if (typeof trigger.alcohol === "boolean") {
if (slots?.alcohol == null) return false;
if (slots.alcohol !== trigger.alcohol) return false;
}
if (always) return true;
if (keywords.length && keywords.some((k) => text.includes(k))) return true;
if (cats.length && categories.some((c) => cats.includes(c))) return true;
return false;
}
function collectAskSlots(rules) {
const out = [];
for (const r of rules) {
const ask = Array.isArray(r.ask_slots) ? r.ask_slots : [];
for (const slot of ask) {
if (slot && slot.slot) out.push(slot);
}
}
return out;
}
function collectQueries({ rules, slots }) {
const out = [];
for (const r of rules) {
const q = Array.isArray(r.queries) ? r.queries : [];
for (const item of q) {
if (!item || typeof item !== "string") continue;
if (item.includes("{alcohol}")) {
const v = slots?.alcohol;
if (v == null) continue;
out.push(item.replace("{alcohol}", v ? "si" : "no"));
continue;
}
out.push(item);
}
}
return out.map((x) => x.trim()).filter(Boolean);
}
function mergeCandidates({ lists, excludeId }) {
const map = new Map();
for (const list of lists) {
for (const c of list || []) {
const id = Number(c?.woo_product_id);
if (!id || (excludeId && id === excludeId)) continue;
const prev = map.get(id);
if (!prev || (c._score || 0) > (prev._score || 0)) map.set(id, c);
}
}
return [...map.values()].sort((a, b) => (b._score || 0) - (a._score || 0));
}
export async function handleRecommend({
tenantId,
text,
prev_context = {},
basket_items = [],
limit = 9,
} = {}) {
const reco = prev_context?.reco && typeof prev_context.reco === "object" ? prev_context.reco : {};
const base_item = reco.base_item || pickBaseItem({ prev_context, basket_items });
const context_patch = { reco: { ...reco, base_item } };
const audit = { base_item, rules_used: [], queries: [] };
if (!base_item?.name) {
return {
reply: "¿Sobre qué producto querés recomendaciones?",
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
};
}
// PRIMERO: Inicializar slots y procesar respuesta pendiente ANTES de filtrar reglas
const slots = { ...(reco.slots || {}) };
let asked_slot = null;
// Procesar respuesta de slot pendiente PRIMERO
if (reco.awaiting_slot === "alcohol") {
const yn = parseYesNo(text);
if (yn != null) {
slots.alcohol = yn;
context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: null };
} else {
return {
reply: "¿Tomás alcohol?",
actions: [],
context_patch: { ...context_patch, reco: { ...context_patch.reco, awaiting_slot: "alcohol" } },
audit,
asked_slot: "alcohol",
candidates: [],
};
}
}
// DESPUÉS: Cargar y filtrar reglas CON SLOTS ACTUALIZADOS
const rulesRaw = await getRecoRules({ tenant_id: tenantId });
const rules = (rulesRaw || []).filter((r) => ruleMatchesBase({ rule: r, base_item, slots }));
audit.rules_used = rules.map((r) => ({ id: r.id, rule_key: r.rule_key, priority: r.priority }));
// Verificar si hay slots pendientes por preguntar
const askSlots = collectAskSlots(rules);
if (!context_patch.reco.awaiting_slot) {
const pending = askSlots.find((s) => s?.slot === "alcohol" && slots.alcohol == null);
if (pending) {
asked_slot = "alcohol";
context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: "alcohol" };
return {
reply: pending.question || "¿Tomás alcohol?",
actions: [],
context_patch,
audit,
asked_slot,
candidates: [],
};
}
}
const queries = collectQueries({ rules, slots });
audit.queries = queries;
const lists = [];
for (const q of queries.slice(0, 6)) {
const { candidates } = await retrieveCandidates({ tenantId, query: q, limit });
lists.push(candidates || []);
}
const merged = mergeCandidates({ lists, excludeId: base_item.product_id });
if (!merged.length) {
return {
reply: `No encontré recomendaciones para ${base_item.name}. ¿Querés que te sugiera algo distinto?`,
actions: [],
context_patch,
audit,
asked_slot: null,
candidates: [],
};
}
const { question, pending } = buildPagedOptions({ candidates: merged, pageSize: Math.min(9, limit) });
let reply = question;
if (process.env.RECO_WRITER === "1") {
const writer = await llmRecommendWriter({
base_item,
slots,
candidates: merged.slice(0, limit),
});
if (writer?.validation?.ok && writer.reply) {
reply = writer.reply;
}
audit.writer = {
ok: Boolean(writer?.validation?.ok),
model: writer?.model || null,
};
}
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
return {
reply,
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
context_patch,
audit,
asked_slot: null,
candidates: merged.slice(0, limit),
};
}

View File

@@ -0,0 +1,16 @@
export function askClarificationReply() {
return "Dale, ¿qué producto querés exactamente?";
}
export function shortSummary(history) {
if (!Array.isArray(history)) return "";
return history
.slice(-5)
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
.join(" | ");
}
export function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
}

View File

@@ -1,6 +1,7 @@
import { llmNluV3 } from "./openai.js"; import { llmNluV3 } from "./openai.js";
import { retrieveCandidates } from "./catalogRetrieval.js"; import { retrieveCandidates } from "./catalogRetrieval.js";
import { safeNextState } from "./fsm.js"; import { safeNextState } from "./fsm.js";
import { handleRecommend } from "./recommendations.js";
function unitAskFor(displayUnit) { function unitAskFor(displayUnit) {
if (displayUnit === "unit") return "¿Cuántas unidades querés?"; if (displayUnit === "unit") return "¿Cuántas unidades querés?";
@@ -19,6 +20,12 @@ function inferDefaultUnit({ name, categories }) {
const cats = Array.isArray(categories) ? categories : []; const cats = Array.isArray(categories) ? categories : [];
const hay = (re) => const hay = (re) =>
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) {
return "unit";
}
if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) {
return "unit";
}
if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) {
return "unit"; return "unit";
} }
@@ -165,6 +172,25 @@ function resolveQuantity({ quantity, unit, displayUnit }) {
function buildPendingItemFromCandidate(candidate) { function buildPendingItemFromCandidate(candidate) {
const displayUnit = inferDefaultUnit({ name: candidate.name, categories: candidate.categories }); 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), product_id: Number(candidate.woo_product_id),
variation_id: null, variation_id: null,
@@ -192,6 +218,173 @@ function hasAddress(ctx) {
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text); return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
} }
/**
* Procesa múltiples items mencionados en un solo mensaje.
* Para cada item: busca en catálogo, si hay match fuerte + cantidad -> agrega al carrito.
* Si hay ambigüedad, para y crea pending_clarification para el primer item ambiguo.
*/
async function processMultiItems({
tenantId,
items,
prev_state,
prev_context,
audit,
}) {
const prev = prev_context && typeof prev_context === "object" ? prev_context : {};
const actions = [];
const context_patch = {};
const addedItems = [];
const addedLabels = [];
let prevItems = Array.isArray(prev?.order_basket?.items) ? [...prev.order_basket.items] : [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const { candidates, audit: catAudit } = await retrieveCandidates({
tenantId,
query: item.product_query,
limit: 12,
});
audit.catalog_multi = audit.catalog_multi || [];
audit.catalog_multi.push({ query: item.product_query, count: candidates?.length || 0 });
if (!candidates.length) {
// No encontrado, seguimos con los demás
continue;
}
const best = candidates[0];
const second = candidates[1];
const strong = candidates.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2));
if (!strong) {
// Ambigüedad: crear pending_clarification para este item y guardar los restantes
const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending;
context_patch.pending_item = null;
// Guardar cantidad pendiente para este item
if (item.quantity != null) {
context_patch.pending_quantity = item.quantity;
context_patch.pending_unit = item.unit;
}
// Guardar items restantes para procesar después
const remainingItems = items.slice(i + 1);
if (remainingItems.length > 0) {
context_patch.pending_multi_items = remainingItems;
}
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
// Si ya agregamos algunos items, incluirlos en el contexto
if (addedItems.length > 0) {
context_patch.order_basket = { items: prevItems };
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
let reply = question;
if (addedLabels.length > 0) {
reply = `Anoté ${addedLabels.join(", ")}. Ahora, para "${item.product_query}":\n\n${question}`;
}
return {
plan: {
reply,
next_state,
intent: "add_to_cart",
missing_fields: ["product_selection"],
order_action: "none",
basket_resolved: { items: addedItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Match fuerte, verificar cantidad
const pendingItem = buildPendingItemFromCandidate(best);
const qty = resolveQuantity({
quantity: item.quantity,
unit: item.unit,
displayUnit: pendingItem.display_unit,
});
if (!qty?.quantity) {
// Sin cantidad: crear pending_item para este y guardar restantes
context_patch.pending_item = pendingItem;
const remainingItems = items.slice(i + 1);
if (remainingItems.length > 0) {
context_patch.pending_multi_items = remainingItems;
}
if (addedItems.length > 0) {
context_patch.order_basket = { items: prevItems };
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
let reply = unitAskFor(pendingItem.display_unit === "unit" ? "unit" : "kg");
if (addedLabels.length > 0) {
reply = `Anoté ${addedLabels.join(", ")}. Para ${pendingItem.name}, ${reply.toLowerCase()}`;
}
return {
plan: {
reply,
next_state,
intent: "add_to_cart",
missing_fields: ["quantity"],
order_action: "none",
basket_resolved: { items: addedItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Todo completo: agregar al carrito
const cartItem = {
product_id: pendingItem.product_id,
variation_id: pendingItem.variation_id,
quantity: qty.quantity,
unit: qty.unit,
label: pendingItem.name,
};
prevItems.push(cartItem);
addedItems.push(cartItem);
actions.push({ type: "add_to_cart", payload: cartItem });
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg de ${pendingItem.name}`
: qty.display_unit === "unit"
? `${qty.display_quantity} ${pendingItem.name}`
: `${qty.display_quantity}g de ${pendingItem.name}`;
addedLabels.push(display);
}
// Todos los items procesados exitosamente
if (addedItems.length > 0) {
context_patch.order_basket = { items: prevItems };
context_patch.pending_item = null;
context_patch.pending_clarification = null;
context_patch.pending_quantity = null;
context_patch.pending_unit = null;
context_patch.pending_multi_items = null;
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
return {
plan: {
reply: `Perfecto, anoto ${addedLabels.join(" y ")}. ¿Algo más?`,
next_state,
intent: "add_to_cart",
missing_fields: [],
order_action: "none",
basket_resolved: { items: addedItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
// Ningún item encontrado
return null;
}
export async function runTurnV3({ export async function runTurnV3({
tenantId, tenantId,
chat_id, chat_id,
@@ -206,6 +399,12 @@ export async function runTurnV3({
const context_patch = {}; const context_patch = {};
const audit = {}; const audit = {};
// Observabilidad (NO se envía al LLM)
audit.trace = {
tenantId: tenantId || null,
chat_id: chat_id || null,
};
const last_shown_options = Array.isArray(prev?.pending_clarification?.options) const last_shown_options = Array.isArray(prev?.pending_clarification?.options)
? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null })) ? prev.pending_clarification.options.map((o) => ({ idx: o.idx, type: o.type, name: o.name, woo_product_id: o.woo_product_id || null }))
: []; : [];
@@ -221,13 +420,100 @@ export async function runTurnV3({
last_shown_options, last_shown_options,
locale: tenant_config?.locale || "es-AR", 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 }; audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H5",
location: "turnEngineV3.js:235",
message: "nlu_result",
data: {
intent: nlu?.intent || null,
needsCatalog: Boolean(nlu?.needs?.catalog_lookup),
has_pending_clarification: Boolean(prev?.pending_clarification?.candidates?.length),
has_pending_item: Boolean(prev?.pending_item?.product_id),
nlu_valid: validation?.ok ?? null,
raw_len: typeof raw_text === "string" ? raw_text.length : null,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
// 0) Procesar multi-items si hay varios productos en un mensaje
// Solo si no hay pending_clarification ni pending_item (flujo limpio)
if (
Array.isArray(nlu?.entities?.items) &&
nlu.entities.items.length > 0 &&
!prev?.pending_clarification?.candidates?.length &&
!prev?.pending_item?.product_id
) {
const multiResult = await processMultiItems({
tenantId,
items: nlu.entities.items,
prev_state,
prev_context: prev,
audit,
});
if (multiResult) {
return multiResult;
}
// Si multiResult es null, ningún item fue encontrado, seguir con flujo normal
}
// 1) Resolver pending_clarification primero // 1) Resolver pending_clarification primero
if (prev?.pending_clarification?.candidates?.length) { if (prev?.pending_clarification?.candidates?.length) {
const resolved = resolvePendingSelection({ text, nlu, pending: prev.pending_clarification }); 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 nextPending = resolved.pending || prev.pending_clarification;
const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question; const reply = resolved.question || buildPagedOptions({ candidates: nextPending.candidates }).question;
@@ -249,9 +535,10 @@ export async function runTurnV3({
} }
if (resolved.kind === "chosen" && resolved.chosen) { if (resolved.kind === "chosen" && resolved.chosen) {
const pendingItem = buildPendingItemFromCandidate(resolved.chosen); const pendingItem = buildPendingItemFromCandidate(resolved.chosen);
// Usar cantidad guardada como fallback si el NLU actual no la tiene
const qty = resolveQuantity({ const qty = resolveQuantity({
quantity: nlu?.entities?.quantity, quantity: nlu?.entities?.quantity ?? prev?.pending_quantity,
unit: nlu?.entities?.unit, unit: nlu?.entities?.unit ?? prev?.pending_unit,
displayUnit: pendingItem.display_unit, displayUnit: pendingItem.display_unit,
}); });
if (qty?.quantity) { if (qty?.quantity) {
@@ -266,7 +553,34 @@ export async function runTurnV3({
context_patch.order_basket = { items: [...prevItems, item] }; context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null; context_patch.pending_item = null;
context_patch.pending_clarification = null; context_patch.pending_clarification = null;
context_patch.pending_quantity = null;
context_patch.pending_unit = null;
actions.push({ type: "add_to_cart", payload: item }); actions.push({ type: "add_to_cart", payload: item });
// Procesar pending_multi_items si hay
const pendingMulti = Array.isArray(prev?.pending_multi_items) ? prev.pending_multi_items : [];
if (pendingMulti.length > 0) {
context_patch.pending_multi_items = null;
const multiResult = await processMultiItems({
tenantId,
items: pendingMulti,
prev_state,
prev_context: { ...prev, ...context_patch },
audit,
});
if (multiResult) {
// Combinar resultados
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg de ${pendingItem.name}`
: qty.display_unit === "unit"
? `${qty.display_quantity} ${pendingItem.name}`
: `${qty.display_quantity}g de ${pendingItem.name}`;
multiResult.plan.reply = `Anoté ${display}. ${multiResult.plan.reply}`;
multiResult.decision.actions = [...actions, ...multiResult.decision.actions];
return multiResult;
}
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true }); const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg" const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg` ? `${qty.display_quantity}kg`
@@ -287,6 +601,7 @@ export async function runTurnV3({
} }
context_patch.pending_item = pendingItem; context_patch.pending_item = pendingItem;
context_patch.pending_clarification = null; context_patch.pending_clarification = null;
// Preservar pending_quantity si había, se usará cuando el usuario dé cantidad
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {}); const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, {});
return { return {
plan: { plan: {
@@ -320,11 +635,32 @@ export async function runTurnV3({
// 2) Si hay pending_item, esperamos cantidad // 2) Si hay pending_item, esperamos cantidad
if (prev?.pending_item?.product_id) { if (prev?.pending_item?.product_id) {
const pendingItem = prev.pending_item; const pendingItem = prev.pending_item;
// Usar cantidad guardada como fallback si el NLU actual no la tiene
const qty = resolveQuantity({ const qty = resolveQuantity({
quantity: nlu?.entities?.quantity, quantity: nlu?.entities?.quantity ?? prev?.pending_quantity,
unit: nlu?.entities?.unit, unit: nlu?.entities?.unit ?? prev?.pending_unit,
displayUnit: pendingItem.display_unit || "kg", 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 = { const item = {
product_id: Number(pendingItem.product_id), product_id: Number(pendingItem.product_id),
@@ -336,7 +672,34 @@ export async function runTurnV3({
const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : []; const prevItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
context_patch.order_basket = { items: [...prevItems, item] }; context_patch.order_basket = { items: [...prevItems, item] };
context_patch.pending_item = null; context_patch.pending_item = null;
context_patch.pending_quantity = null;
context_patch.pending_unit = null;
actions.push({ type: "add_to_cart", payload: item }); actions.push({ type: "add_to_cart", payload: item });
// Procesar pending_multi_items si hay
const pendingMulti = Array.isArray(prev?.pending_multi_items) ? prev.pending_multi_items : [];
if (pendingMulti.length > 0) {
context_patch.pending_multi_items = null;
const multiResult = await processMultiItems({
tenantId,
items: pendingMulti,
prev_state,
prev_context: { ...prev, ...context_patch },
audit,
});
if (multiResult) {
// Combinar resultados
const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg de ${item.label}`
: qty.display_unit === "unit"
? `${qty.display_quantity} ${item.label}`
: `${qty.display_quantity}g de ${item.label}`;
multiResult.plan.reply = `Anoté ${display}. ${multiResult.plan.reply}`;
multiResult.decision.actions = [...actions, ...multiResult.decision.actions];
return multiResult;
}
}
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true }); const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { pending_item_completed: true });
const display = qty.display_unit === "kg" const display = qty.display_unit === "kg"
? `${qty.display_quantity}kg` ? `${qty.display_quantity}kg`
@@ -371,8 +734,71 @@ export async function runTurnV3({
// 3) Intento normal // 3) Intento normal
const intent = nlu?.intent || "other"; const intent = nlu?.intent || "other";
const productQuery = String(nlu?.entities?.product_query || "").trim(); let productQuery = String(nlu?.entities?.product_query || "").trim();
const needsCatalog = Boolean(nlu?.needs?.catalog_lookup); const needsCatalog = Boolean(nlu?.needs?.catalog_lookup);
const lastBasketItem = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items.slice(-1)[0] : null;
const fallbackQuery =
!productQuery && intent === "browse"
? (prev?.pending_item?.name || lastBasketItem?.label || lastBasketItem?.name || null)
: null;
if (fallbackQuery) {
productQuery = String(fallbackQuery).trim();
// #region agent log
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "debug-session",
runId: "pre-fix",
hypothesisId: "H13",
location: "turnEngineV3.js:390",
message: "browse_fallback_query",
data: {
fallback: productQuery,
has_basket: Boolean(lastBasketItem),
has_pending_item: Boolean(prev?.pending_item?.name),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
if (intent === "recommend") {
const basketItems = Array.isArray(prev?.order_basket?.items) ? prev.order_basket.items : [];
const rec = await handleRecommend({
tenantId,
text,
prev_context: prev,
basket_items: basketItems,
});
if (rec?.actions?.length) actions.push(...rec.actions);
if (rec?.context_patch) Object.assign(context_patch, rec.context_patch);
if (rec?.audit) audit.recommend = rec.audit;
const didShowOptions = actions.some((a) => a?.type === "show_options");
const { next_state, validation: v } = safeNextState(
prev_state,
{ ...prev, ...context_patch },
{ did_show_options: didShowOptions, is_browsing: didShowOptions }
);
const missing_fields = [];
if (rec?.asked_slot) missing_fields.push(rec.asked_slot);
if (didShowOptions) missing_fields.push("product_selection");
if (!rec?.asked_slot && !didShowOptions && !basketItems.length && !rec?.candidates?.length) {
missing_fields.push("recommend_base");
}
return {
plan: {
reply: rec?.reply || "¿Qué te gustaría que te recomiende?",
next_state,
intent: "recommend",
missing_fields,
order_action: "none",
basket_resolved: { items: basketItems },
},
decision: { actions, context_patch, audit: { ...audit, fsm: v } },
};
}
if (intent === "greeting") { if (intent === "greeting") {
const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {}); const { next_state, validation: v } = safeNextState(prev_state, { ...prev }, {});
@@ -484,6 +910,11 @@ export async function runTurnV3({
const { question, pending } = buildPagedOptions({ candidates }); const { question, pending } = buildPagedOptions({ candidates });
context_patch.pending_clarification = pending; context_patch.pending_clarification = pending;
context_patch.pending_item = null; context_patch.pending_item = null;
// Guardar cantidad pendiente para usarla después de la selección
if (nlu?.entities?.quantity != null) {
context_patch.pending_quantity = nlu.entities.quantity;
context_patch.pending_unit = nlu.entities.unit;
}
actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } }); actions.push({ type: "show_options", payload: { count: pending.options?.length || 0 } });
const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true }); const { next_state, validation: v } = safeNextState(prev_state, { ...prev, ...context_patch }, { did_show_options: true });
return { return {

View File

@@ -0,0 +1,112 @@
function parseIndexSelection(text) {
const t = String(text || "").toLowerCase();
const m = /\b(\d{1,2})\b/.exec(t);
if (m) return parseInt(m[1], 10);
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
if (/\btercera\b|\btercero\b/.test(t)) return 3;
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
return null;
}
function isShowMoreRequest(text) {
const t = String(text || "").toLowerCase();
return (
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
/\bmas\s+opciones\b/.test(t) ||
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
/\bsiguiente(s)?\b/.test(t)
);
}
function normalizeText(s) {
return String(s || "")
.toLowerCase()
.replace(/[¿?¡!.,;:()"]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreTextMatch(query, candidateName) {
const qt = new Set(normalizeText(query).split(" ").filter(Boolean));
const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean));
let hits = 0;
for (const w of qt) if (nt.has(w)) hits++;
return hits / Math.max(qt.size, 1);
}
export function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) {
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
const off = Math.max(0, parseInt(candidateOffset, 10) || 0);
const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9));
const slice = cands.slice(off, off + size);
const options = slice.map((c, i) => ({
idx: baseIdx + i,
type: "product",
woo_product_id: c.woo_product_id,
name: c.name,
}));
const hasMore = off + size < cands.length;
if (hasMore) options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" });
const list = options
.map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}`))
.join("\n");
const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`;
const pending = {
candidates: cands,
options,
candidate_offset: off,
page_size: size,
base_idx: baseIdx,
has_more: hasMore,
next_candidate_offset: off + size,
next_base_idx: baseIdx + size + (hasMore ? 1 : 0),
};
return { question, pending, options, hasMore };
}
export function resolvePendingSelection({ text, nlu, pending }) {
if (!pending?.candidates?.length) return { kind: "none" };
if (isShowMoreRequest(text)) {
const { question, pending: nextPending } = buildPagedOptions({
candidates: pending.candidates,
candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)),
baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1),
pageSize: pending.page_size || 9,
});
return { kind: "more", question, pending: nextPending };
}
const idx =
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
parseIndexSelection(text);
if (idx && Array.isArray(pending.options)) {
const opt = pending.options.find((o) => o.idx === idx);
if (opt?.type === "more") return { kind: "more", question: null, pending };
if (opt?.woo_product_id) {
const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
if (chosen) return { kind: "chosen", chosen };
}
}
const selText = nlu?.entities?.selection?.type === "text" ? String(nlu.entities.selection.value || "").trim() : null;
const q = selText || nlu?.entities?.product_query || null;
if (q) {
const scored = pending.candidates
.map((c) => ({ c, s: scoreTextMatch(q, c?.name) }))
.sort((a, b) => b.s - a.s);
if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) {
return { kind: "chosen", chosen: scored[0].c };
}
}
return { kind: "ask" };
}

View File

@@ -0,0 +1,51 @@
export function unitAskFor(displayUnit) {
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
if (displayUnit === "g") return "¿Cuántos gramos querés?";
return "¿Cuántos kilos querés?";
}
export function unitDisplay(unit) {
if (unit === "unit") return "unidades";
if (unit === "g") return "gramos";
return "kilos";
}
export function inferDefaultUnit({ name, categories }) {
const n = String(name || "").toLowerCase();
const cats = Array.isArray(categories) ? categories : [];
const hay = (re) =>
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
if (
hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)
) {
return "unit";
}
return "kg";
}
export function normalizeUnit(unit) {
if (!unit) return null;
const u = String(unit).toLowerCase();
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
if (u === "g" || u === "gramo" || u === "gramos") return "g";
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
return null;
}
export function resolveQuantity({ quantity, unit, displayUnit }) {
if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null;
const q = Number(quantity);
const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg");
if (u === "unit") {
return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" };
}
if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" };
// kg -> gramos enteros
return {
quantity: Math.round(q * 1000),
unit: "g",
display_unit: "kg",
display_quantity: q,
};
}

View File

@@ -146,6 +146,35 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
const query = String(q || "").trim(); const query = String(q || "").trim();
if (!query) return { items: [], source: "snapshot" }; if (!query) return { items: [], source: "snapshot" };
const like = `%${query}%`; 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 * select *
from sellable_items from sellable_items
@@ -155,6 +184,25 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
limit $3 limit $3
`; `;
const { rows } = await pool.query(sql, [tenantId, like, lim]); 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" };
} }