ux improved
This commit is contained in:
153
.cursor/debug.log
Normal file
153
.cursor/debug.log
Normal 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}
|
||||||
24
db/migrations/20260117120000_product_reco_rules.sql
Normal file
24
db/migrations/20260117120000_product_reco_rules.sql
Normal 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;
|
||||||
49
db/migrations/20260117121000_product_reco_rules_seed_ar.sql
Normal file
49
db/migrations/20260117121000_product_reco_rules_seed_ar.sql
Normal 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
44
env.example
Normal 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
|
||||||
@@ -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();
|
||||||
|
|||||||
278
public/components/aliases-crud.js
Normal file
278
public/components/aliases-crud.js
Normal 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);
|
||||||
@@ -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>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="field">
|
||||||
<input id="evoFrom" style="flex:1" value="5491133230322@s.whatsapp.net" placeholder="from (remoteJid)" />
|
<label>From (remoteJid)</label>
|
||||||
|
<input id="evoFrom" value="5491133230322@s.whatsapp.net" placeholder="from" />
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="field">
|
||||||
<input id="evoTo" style="flex:1" value="5491137887040@s.whatsapp.net" placeholder="to (destino receptor)" />
|
<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 class="col">
|
||||||
|
<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 class="row" style="margin-top:8px">
|
|
||||||
<input id="pushName" style="flex:1" value="test_lucas" placeholder="pushName (opcional)" />
|
|
||||||
</div>
|
</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>
|
||||||
<div class="status" id="status">—</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -124,6 +145,17 @@ class ChatSimulator extends HTMLElement {
|
|||||||
});
|
});
|
||||||
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() } } } };
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|||||||
308
public/components/conversation-inspector.js
Normal file
308
public/components/conversation-inspector.js
Normal 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);
|
||||||
|
|
||||||
267
public/components/conversations-crud.js
Normal file
267
public/components/conversations-crud.js
Normal 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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
267
public/components/products-crud.js
Normal file
267
public/components/products-crud.js
Normal 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);
|
||||||
320
public/components/recommendations-crud.js
Normal file
320
public/components/recommendations-crud.js
Normal 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);
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
243
public/components/users-crud.js
Normal file
243
public/components/users-crud.js
Normal 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);
|
||||||
@@ -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());
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
68
src/modules/0-ui/controllers/aliases.js
Normal file
68
src/modules/0-ui/controllers/aliases.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { handleListAliases, handleCreateAlias, handleUpdateAlias, handleDeleteAlias } from "../handlers/aliases.js";
|
||||||
|
|
||||||
|
export const makeListAliases = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const q = req.query.q || "";
|
||||||
|
const woo_product_id = req.query.woo_product_id ? parseInt(req.query.woo_product_id, 10) : null;
|
||||||
|
const limit = req.query.limit || "200";
|
||||||
|
const result = await handleListAliases({ tenantId, q, woo_product_id, limit });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeCreateAlias = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const { alias, woo_product_id, boost, category_hint, metadata } = req.body || {};
|
||||||
|
|
||||||
|
if (!alias || !woo_product_id) {
|
||||||
|
return res.status(400).json({ ok: false, error: "alias_and_woo_product_id_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await handleCreateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
|
||||||
|
res.json({ ok: true, item: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (err.code === "23505") { // unique violation
|
||||||
|
return res.status(409).json({ ok: false, error: "alias_already_exists" });
|
||||||
|
}
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeUpdateAlias = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const alias = req.params.alias;
|
||||||
|
const { woo_product_id, boost, category_hint, metadata } = req.body || {};
|
||||||
|
|
||||||
|
if (!woo_product_id) {
|
||||||
|
return res.status(400).json({ ok: false, error: "woo_product_id_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await handleUpdateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ ok: false, error: "alias_not_found" });
|
||||||
|
}
|
||||||
|
res.json({ ok: true, item: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeDeleteAlias = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const alias = req.params.alias;
|
||||||
|
const result = await handleDeleteAlias({ tenantId, alias });
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { handleSearchProducts } from "../handlers/products.js";
|
import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts } from "../handlers/products.js";
|
||||||
|
|
||||||
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
|
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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
88
src/modules/0-ui/controllers/recommendations.js
Normal file
88
src/modules/0-ui/controllers/recommendations.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
handleListRecommendations,
|
||||||
|
handleGetRecommendation,
|
||||||
|
handleCreateRecommendation,
|
||||||
|
handleUpdateRecommendation,
|
||||||
|
handleDeleteRecommendation
|
||||||
|
} from "../handlers/recommendations.js";
|
||||||
|
|
||||||
|
export const makeListRecommendations = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const q = req.query.q || "";
|
||||||
|
const limit = req.query.limit || "200";
|
||||||
|
const result = await handleListRecommendations({ tenantId, q, limit });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeGetRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await handleGetRecommendation({ tenantId, id });
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeCreateRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const { rule_key, trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
|
||||||
|
|
||||||
|
if (!rule_key) {
|
||||||
|
return res.status(400).json({ ok: false, error: "rule_key_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await handleCreateRecommendation({
|
||||||
|
tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority
|
||||||
|
});
|
||||||
|
res.json({ ok: true, item: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (err.code === "23505") { // unique violation
|
||||||
|
return res.status(409).json({ ok: false, error: "rule_key_already_exists" });
|
||||||
|
}
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeUpdateRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const id = req.params.id;
|
||||||
|
const { trigger, queries, boosts, ask_slots, active, priority } = req.body || {};
|
||||||
|
|
||||||
|
const result = await handleUpdateRecommendation({
|
||||||
|
tenantId, id, trigger, queries, boosts, ask_slots, active, priority
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ ok: false, error: "recommendation_not_found" });
|
||||||
|
}
|
||||||
|
res.json({ ok: true, item: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeDeleteRecommendation = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await handleDeleteRecommendation({ tenantId, id });
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
296
src/modules/0-ui/db/repo.js
Normal file
296
src/modules/0-ui/db/repo.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { pool } from "../../shared/db/pool.js";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Products
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 }) {
|
||||||
|
const lim = Math.max(1, Math.min(5000, parseInt(limit, 10) || 2000));
|
||||||
|
const off = Math.max(0, parseInt(offset, 10) || 0);
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
|
||||||
|
let sql, params;
|
||||||
|
if (query) {
|
||||||
|
const like = `%${query}%`;
|
||||||
|
sql = `
|
||||||
|
select
|
||||||
|
woo_id as woo_product_id,
|
||||||
|
name,
|
||||||
|
slug as sku,
|
||||||
|
price_current as price,
|
||||||
|
stock_status,
|
||||||
|
categories,
|
||||||
|
attributes_normalized,
|
||||||
|
updated_at as refreshed_at,
|
||||||
|
raw as payload
|
||||||
|
from woo_products_snapshot
|
||||||
|
where tenant_id = $1
|
||||||
|
and (name ilike $2 or coalesce(slug,'') ilike $2)
|
||||||
|
order by name asc
|
||||||
|
limit $3 offset $4
|
||||||
|
`;
|
||||||
|
params = [tenantId, like, lim, off];
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
select
|
||||||
|
woo_id as woo_product_id,
|
||||||
|
name,
|
||||||
|
slug as sku,
|
||||||
|
price_current as price,
|
||||||
|
stock_status,
|
||||||
|
categories,
|
||||||
|
attributes_normalized,
|
||||||
|
updated_at as refreshed_at,
|
||||||
|
raw as payload
|
||||||
|
from woo_products_snapshot
|
||||||
|
where tenant_id = $1
|
||||||
|
order by name asc
|
||||||
|
limit $2 offset $3
|
||||||
|
`;
|
||||||
|
params = [tenantId, lim, off];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductByWooId({ tenantId, wooProductId }) {
|
||||||
|
const sql = `
|
||||||
|
select
|
||||||
|
woo_id as woo_product_id,
|
||||||
|
name,
|
||||||
|
slug as sku,
|
||||||
|
price_current as price,
|
||||||
|
stock_status,
|
||||||
|
categories,
|
||||||
|
attributes_normalized,
|
||||||
|
updated_at as refreshed_at,
|
||||||
|
raw as payload
|
||||||
|
from woo_products_snapshot
|
||||||
|
where tenant_id = $1 and woo_id = $2
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, wooProductId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Aliases
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function normalizeAlias(alias) {
|
||||||
|
return String(alias || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
|
||||||
|
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
|
||||||
|
let sql, params;
|
||||||
|
if (woo_product_id) {
|
||||||
|
sql = `
|
||||||
|
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
|
||||||
|
from product_aliases
|
||||||
|
where tenant_id = $1 and woo_product_id = $2
|
||||||
|
order by alias asc
|
||||||
|
limit $3
|
||||||
|
`;
|
||||||
|
params = [tenantId, woo_product_id, lim];
|
||||||
|
} else if (query) {
|
||||||
|
const like = `%${query}%`;
|
||||||
|
sql = `
|
||||||
|
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
|
||||||
|
from product_aliases
|
||||||
|
where tenant_id = $1 and (alias ilike $2 or normalized_alias ilike $2)
|
||||||
|
order by alias asc
|
||||||
|
limit $3
|
||||||
|
`;
|
||||||
|
params = [tenantId, like, lim];
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
select alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
|
||||||
|
from product_aliases
|
||||||
|
where tenant_id = $1
|
||||||
|
order by alias asc
|
||||||
|
limit $2
|
||||||
|
`;
|
||||||
|
params = [tenantId, lim];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
|
||||||
|
const normalizedAlias = normalizeAlias(alias);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
insert into product_aliases (tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, [
|
||||||
|
tenantId,
|
||||||
|
alias.toLowerCase().trim(),
|
||||||
|
normalizedAlias,
|
||||||
|
woo_product_id,
|
||||||
|
category_hint,
|
||||||
|
boost || 0,
|
||||||
|
JSON.stringify(metadata || {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
|
||||||
|
const normalizedAlias = normalizeAlias(alias);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
update product_aliases
|
||||||
|
set woo_product_id = $3, category_hint = $4, boost = $5, metadata = $6, normalized_alias = $7, updated_at = now()
|
||||||
|
where tenant_id = $1 and alias = $2
|
||||||
|
returning alias, normalized_alias, woo_product_id, category_hint, boost, metadata, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, [
|
||||||
|
tenantId,
|
||||||
|
alias.toLowerCase().trim(),
|
||||||
|
woo_product_id,
|
||||||
|
category_hint,
|
||||||
|
boost || 0,
|
||||||
|
JSON.stringify(metadata || {}),
|
||||||
|
normalizedAlias,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAlias({ tenantId, alias }) {
|
||||||
|
const sql = `delete from product_aliases where tenant_id = $1 and alias = $2 returning alias`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, alias.toLowerCase().trim()]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Recommendations
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listRecommendations({ tenantId, q = "", limit = 200 }) {
|
||||||
|
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
|
||||||
|
let sql, params;
|
||||||
|
if (query) {
|
||||||
|
const like = `%${query}%`;
|
||||||
|
sql = `
|
||||||
|
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||||
|
from product_reco_rules
|
||||||
|
where tenant_id = $1 and rule_key ilike $2
|
||||||
|
order by priority desc, rule_key asc
|
||||||
|
limit $3
|
||||||
|
`;
|
||||||
|
params = [tenantId, like, lim];
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||||
|
from product_reco_rules
|
||||||
|
where tenant_id = $1
|
||||||
|
order by priority desc, rule_key asc
|
||||||
|
limit $2
|
||||||
|
`;
|
||||||
|
params = [tenantId, lim];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecommendationById({ tenantId, id }) {
|
||||||
|
const sql = `
|
||||||
|
select id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||||
|
from product_reco_rules
|
||||||
|
where tenant_id = $1 and id = $2
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertRecommendation({
|
||||||
|
tenantId,
|
||||||
|
rule_key,
|
||||||
|
trigger = {},
|
||||||
|
queries = [],
|
||||||
|
boosts = {},
|
||||||
|
ask_slots = [],
|
||||||
|
active = true,
|
||||||
|
priority = 100,
|
||||||
|
}) {
|
||||||
|
const sql = `
|
||||||
|
insert into product_reco_rules (tenant_id, rule_key, trigger, queries, boosts, ask_slots, active, priority)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, [
|
||||||
|
tenantId,
|
||||||
|
rule_key.toLowerCase().trim(),
|
||||||
|
JSON.stringify(trigger || {}),
|
||||||
|
JSON.stringify(queries || []),
|
||||||
|
JSON.stringify(boosts || {}),
|
||||||
|
JSON.stringify(ask_slots || []),
|
||||||
|
active !== false,
|
||||||
|
priority || 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecommendation({
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
trigger,
|
||||||
|
queries,
|
||||||
|
boosts,
|
||||||
|
ask_slots,
|
||||||
|
active,
|
||||||
|
priority,
|
||||||
|
}) {
|
||||||
|
const sql = `
|
||||||
|
update product_reco_rules
|
||||||
|
set
|
||||||
|
trigger = $3,
|
||||||
|
queries = $4,
|
||||||
|
boosts = $5,
|
||||||
|
ask_slots = $6,
|
||||||
|
active = $7,
|
||||||
|
priority = $8,
|
||||||
|
updated_at = now()
|
||||||
|
where tenant_id = $1 and id = $2
|
||||||
|
returning id, rule_key, trigger, queries, boosts, ask_slots, active, priority, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, [
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
JSON.stringify(trigger || {}),
|
||||||
|
JSON.stringify(queries || []),
|
||||||
|
JSON.stringify(boosts || {}),
|
||||||
|
JSON.stringify(ask_slots || []),
|
||||||
|
active !== false,
|
||||||
|
priority || 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecommendation({ tenantId, id }) {
|
||||||
|
const sql = `delete from product_reco_rules where tenant_id = $1 and id = $2 returning id`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, id]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
19
src/modules/0-ui/handlers/aliases.js
Normal file
19
src/modules/0-ui/handlers/aliases.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { listAliases, insertAlias, updateAlias, deleteAlias } from "../db/repo.js";
|
||||||
|
|
||||||
|
export async function handleListAliases({ tenantId, q = "", woo_product_id = null, limit = 200 }) {
|
||||||
|
const items = await listAliases({ tenantId, q, woo_product_id, limit });
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
|
||||||
|
return insertAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateAlias({ tenantId, alias, woo_product_id, boost = 0, category_hint = null, metadata = {} }) {
|
||||||
|
return updateAlias({ tenantId, alias, woo_product_id, boost, category_hint, metadata });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteAlias({ tenantId, alias }) {
|
||||||
|
const deleted = await deleteAlias({ tenantId, alias });
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
|
import { 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)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/modules/0-ui/handlers/recommendations.js
Normal file
47
src/modules/0-ui/handlers/recommendations.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
listRecommendations,
|
||||||
|
getRecommendationById,
|
||||||
|
insertRecommendation,
|
||||||
|
updateRecommendation,
|
||||||
|
deleteRecommendation,
|
||||||
|
} from "../db/repo.js";
|
||||||
|
|
||||||
|
export async function handleListRecommendations({ tenantId, q = "", limit = 200 }) {
|
||||||
|
const items = await listRecommendations({ tenantId, q, limit });
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetRecommendation({ tenantId, id }) {
|
||||||
|
return getRecommendationById({ tenantId, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateRecommendation({
|
||||||
|
tenantId,
|
||||||
|
rule_key,
|
||||||
|
trigger = {},
|
||||||
|
queries = [],
|
||||||
|
boosts = {},
|
||||||
|
ask_slots = [],
|
||||||
|
active = true,
|
||||||
|
priority = 100,
|
||||||
|
}) {
|
||||||
|
return insertRecommendation({ tenantId, rule_key, trigger, queries, boosts, ask_slots, active, priority });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateRecommendation({
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
trigger,
|
||||||
|
queries,
|
||||||
|
boosts,
|
||||||
|
ask_slots,
|
||||||
|
active,
|
||||||
|
priority,
|
||||||
|
}) {
|
||||||
|
return updateRecommendation({ tenantId, id, trigger, queries, boosts, ask_slots, active, priority });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteRecommendation({ tenantId, id }) {
|
||||||
|
const deleted = await deleteRecommendation({ tenantId, id });
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
@@ -6,6 +6,26 @@ import { debug as dbg } from "../../shared/debug.js";
|
|||||||
export async function handleEvolutionWebhook(body) {
|
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 } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
217
src/modules/3-turn-engine/recommendations.js
Normal file
217
src/modules/3-turn-engine/recommendations.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { getRecoRules } from "../2-identity/db/repo.js";
|
||||||
|
import { retrieveCandidates } from "./catalogRetrieval.js";
|
||||||
|
import { buildPagedOptions } from "./turnEngineV3.pendingSelection.js";
|
||||||
|
import { llmRecommendWriter } from "./openai.js";
|
||||||
|
|
||||||
|
function normalizeText(s) {
|
||||||
|
return String(s || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[¿?¡!.,;:()"]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYesNo(text) {
|
||||||
|
const t = normalizeText(text);
|
||||||
|
if (!t) return null;
|
||||||
|
if (/\b(si|sí|sisi|dale|ok|claro|obvio|tomo)\b/.test(t)) return true;
|
||||||
|
if (/\b(no|nop|nunca|nope|sin alcohol)\b/.test(t)) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBaseItem({ prev_context, basket_items }) {
|
||||||
|
const pending = prev_context?.pending_item;
|
||||||
|
if (pending?.name) {
|
||||||
|
return {
|
||||||
|
product_id: pending.product_id || null,
|
||||||
|
name: pending.name,
|
||||||
|
label: pending.name,
|
||||||
|
categories: pending.categories || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const items = Array.isArray(basket_items) ? basket_items : [];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (!last) return null;
|
||||||
|
return {
|
||||||
|
product_id: last.product_id || null,
|
||||||
|
name: last.label || last.name || "ese producto",
|
||||||
|
label: last.label || last.name || "ese producto",
|
||||||
|
categories: last.categories || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleMatchesBase({ rule, base_item, slots }) {
|
||||||
|
const trigger = rule?.trigger && typeof rule.trigger === "object" ? rule.trigger : {};
|
||||||
|
const text = normalizeText(base_item?.name || base_item?.label || "");
|
||||||
|
const categories = Array.isArray(base_item?.categories) ? base_item.categories.map((c) => normalizeText(c?.name || c)) : [];
|
||||||
|
const keywords = Array.isArray(trigger.keywords) ? trigger.keywords.map(normalizeText).filter(Boolean) : [];
|
||||||
|
const cats = Array.isArray(trigger.categories) ? trigger.categories.map(normalizeText).filter(Boolean) : [];
|
||||||
|
const always = Boolean(trigger.always);
|
||||||
|
if (typeof trigger.alcohol === "boolean") {
|
||||||
|
if (slots?.alcohol == null) return false;
|
||||||
|
if (slots.alcohol !== trigger.alcohol) return false;
|
||||||
|
}
|
||||||
|
if (always) return true;
|
||||||
|
if (keywords.length && keywords.some((k) => text.includes(k))) return true;
|
||||||
|
if (cats.length && categories.some((c) => cats.includes(c))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAskSlots(rules) {
|
||||||
|
const out = [];
|
||||||
|
for (const r of rules) {
|
||||||
|
const ask = Array.isArray(r.ask_slots) ? r.ask_slots : [];
|
||||||
|
for (const slot of ask) {
|
||||||
|
if (slot && slot.slot) out.push(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectQueries({ rules, slots }) {
|
||||||
|
const out = [];
|
||||||
|
for (const r of rules) {
|
||||||
|
const q = Array.isArray(r.queries) ? r.queries : [];
|
||||||
|
for (const item of q) {
|
||||||
|
if (!item || typeof item !== "string") continue;
|
||||||
|
if (item.includes("{alcohol}")) {
|
||||||
|
const v = slots?.alcohol;
|
||||||
|
if (v == null) continue;
|
||||||
|
out.push(item.replace("{alcohol}", v ? "si" : "no"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.map((x) => x.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCandidates({ lists, excludeId }) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const c of list || []) {
|
||||||
|
const id = Number(c?.woo_product_id);
|
||||||
|
if (!id || (excludeId && id === excludeId)) continue;
|
||||||
|
const prev = map.get(id);
|
||||||
|
if (!prev || (c._score || 0) > (prev._score || 0)) map.set(id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (b._score || 0) - (a._score || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRecommend({
|
||||||
|
tenantId,
|
||||||
|
text,
|
||||||
|
prev_context = {},
|
||||||
|
basket_items = [],
|
||||||
|
limit = 9,
|
||||||
|
} = {}) {
|
||||||
|
const reco = prev_context?.reco && typeof prev_context.reco === "object" ? prev_context.reco : {};
|
||||||
|
const base_item = reco.base_item || pickBaseItem({ prev_context, basket_items });
|
||||||
|
const context_patch = { reco: { ...reco, base_item } };
|
||||||
|
const audit = { base_item, rules_used: [], queries: [] };
|
||||||
|
|
||||||
|
if (!base_item?.name) {
|
||||||
|
return {
|
||||||
|
reply: "¿Sobre qué producto querés recomendaciones?",
|
||||||
|
actions: [],
|
||||||
|
context_patch,
|
||||||
|
audit,
|
||||||
|
asked_slot: null,
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIMERO: Inicializar slots y procesar respuesta pendiente ANTES de filtrar reglas
|
||||||
|
const slots = { ...(reco.slots || {}) };
|
||||||
|
let asked_slot = null;
|
||||||
|
|
||||||
|
// Procesar respuesta de slot pendiente PRIMERO
|
||||||
|
if (reco.awaiting_slot === "alcohol") {
|
||||||
|
const yn = parseYesNo(text);
|
||||||
|
if (yn != null) {
|
||||||
|
slots.alcohol = yn;
|
||||||
|
context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: null };
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
reply: "¿Tomás alcohol?",
|
||||||
|
actions: [],
|
||||||
|
context_patch: { ...context_patch, reco: { ...context_patch.reco, awaiting_slot: "alcohol" } },
|
||||||
|
audit,
|
||||||
|
asked_slot: "alcohol",
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DESPUÉS: Cargar y filtrar reglas CON SLOTS ACTUALIZADOS
|
||||||
|
const rulesRaw = await getRecoRules({ tenant_id: tenantId });
|
||||||
|
const rules = (rulesRaw || []).filter((r) => ruleMatchesBase({ rule: r, base_item, slots }));
|
||||||
|
audit.rules_used = rules.map((r) => ({ id: r.id, rule_key: r.rule_key, priority: r.priority }));
|
||||||
|
|
||||||
|
// Verificar si hay slots pendientes por preguntar
|
||||||
|
const askSlots = collectAskSlots(rules);
|
||||||
|
if (!context_patch.reco.awaiting_slot) {
|
||||||
|
const pending = askSlots.find((s) => s?.slot === "alcohol" && slots.alcohol == null);
|
||||||
|
if (pending) {
|
||||||
|
asked_slot = "alcohol";
|
||||||
|
context_patch.reco = { ...context_patch.reco, slots, awaiting_slot: "alcohol" };
|
||||||
|
return {
|
||||||
|
reply: pending.question || "¿Tomás alcohol?",
|
||||||
|
actions: [],
|
||||||
|
context_patch,
|
||||||
|
audit,
|
||||||
|
asked_slot,
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = collectQueries({ rules, slots });
|
||||||
|
audit.queries = queries;
|
||||||
|
const lists = [];
|
||||||
|
for (const q of queries.slice(0, 6)) {
|
||||||
|
const { candidates } = await retrieveCandidates({ tenantId, query: q, limit });
|
||||||
|
lists.push(candidates || []);
|
||||||
|
}
|
||||||
|
const merged = mergeCandidates({ lists, excludeId: base_item.product_id });
|
||||||
|
|
||||||
|
if (!merged.length) {
|
||||||
|
return {
|
||||||
|
reply: `No encontré recomendaciones para ${base_item.name}. ¿Querés que te sugiera algo distinto?`,
|
||||||
|
actions: [],
|
||||||
|
context_patch,
|
||||||
|
audit,
|
||||||
|
asked_slot: null,
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { question, pending } = buildPagedOptions({ candidates: merged, pageSize: Math.min(9, limit) });
|
||||||
|
let reply = question;
|
||||||
|
if (process.env.RECO_WRITER === "1") {
|
||||||
|
const writer = await llmRecommendWriter({
|
||||||
|
base_item,
|
||||||
|
slots,
|
||||||
|
candidates: merged.slice(0, limit),
|
||||||
|
});
|
||||||
|
if (writer?.validation?.ok && writer.reply) {
|
||||||
|
reply = writer.reply;
|
||||||
|
}
|
||||||
|
audit.writer = {
|
||||||
|
ok: Boolean(writer?.validation?.ok),
|
||||||
|
model: writer?.model || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
context_patch.pending_clarification = pending;
|
||||||
|
context_patch.pending_item = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reply,
|
||||||
|
actions: [{ type: "show_options", payload: { count: pending.options?.length || 0 } }],
|
||||||
|
context_patch,
|
||||||
|
audit,
|
||||||
|
asked_slot: null,
|
||||||
|
candidates: merged.slice(0, limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/modules/3-turn-engine/turnEngineV3.helpers.js
Normal file
16
src/modules/3-turn-engine/turnEngineV3.helpers.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function askClarificationReply() {
|
||||||
|
return "Dale, ¿qué producto querés exactamente?";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortSummary(history) {
|
||||||
|
if (!Array.isArray(history)) return "";
|
||||||
|
return history
|
||||||
|
.slice(-5)
|
||||||
|
.map((m) => `${m.role === "user" ? "U" : "A"}:${String(m.content || "").slice(0, 120)}`)
|
||||||
|
.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAddress(ctx) {
|
||||||
|
return Boolean(ctx?.delivery_address?.text || ctx?.address?.text || ctx?.address_text);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { llmNluV3 } from "./openai.js";
|
import { 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 {
|
||||||
|
|||||||
112
src/modules/3-turn-engine/turnEngineV3.pendingSelection.js
Normal file
112
src/modules/3-turn-engine/turnEngineV3.pendingSelection.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
function parseIndexSelection(text) {
|
||||||
|
const t = String(text || "").toLowerCase();
|
||||||
|
const m = /\b(\d{1,2})\b/.exec(t);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
if (/\bprimera\b|\bprimero\b/.test(t)) return 1;
|
||||||
|
if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2;
|
||||||
|
if (/\btercera\b|\btercero\b/.test(t)) return 3;
|
||||||
|
if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4;
|
||||||
|
if (/\bquinta\b|\bquinto\b/.test(t)) return 5;
|
||||||
|
if (/\bsexta\b|\bsexto\b/.test(t)) return 6;
|
||||||
|
if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7;
|
||||||
|
if (/\boctava\b|\boctavo\b/.test(t)) return 8;
|
||||||
|
if (/\bnovena\b|\bnoveno\b/.test(t)) return 9;
|
||||||
|
if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShowMoreRequest(text) {
|
||||||
|
const t = String(text || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
/\bmostr(a|ame)\s+m[aá]s\b/.test(t) ||
|
||||||
|
/\bmas\s+opciones\b/.test(t) ||
|
||||||
|
(/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) ||
|
||||||
|
/\bsiguiente(s)?\b/.test(t)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(s) {
|
||||||
|
return String(s || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[¿?¡!.,;:()"]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTextMatch(query, candidateName) {
|
||||||
|
const qt = new Set(normalizeText(query).split(" ").filter(Boolean));
|
||||||
|
const nt = new Set(normalizeText(candidateName).split(" ").filter(Boolean));
|
||||||
|
let hits = 0;
|
||||||
|
for (const w of qt) if (nt.has(w)) hits++;
|
||||||
|
return hits / Math.max(qt.size, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPagedOptions({ candidates, candidateOffset = 0, baseIdx = 1, pageSize = 9 }) {
|
||||||
|
const cands = (candidates || []).filter((c) => c && c.woo_product_id && c.name);
|
||||||
|
const off = Math.max(0, parseInt(candidateOffset, 10) || 0);
|
||||||
|
const size = Math.max(1, Math.min(20, parseInt(pageSize, 10) || 9));
|
||||||
|
const slice = cands.slice(off, off + size);
|
||||||
|
const options = slice.map((c, i) => ({
|
||||||
|
idx: baseIdx + i,
|
||||||
|
type: "product",
|
||||||
|
woo_product_id: c.woo_product_id,
|
||||||
|
name: c.name,
|
||||||
|
}));
|
||||||
|
const hasMore = off + size < cands.length;
|
||||||
|
if (hasMore) options.push({ idx: baseIdx + size, type: "more", name: "Mostrame más…" });
|
||||||
|
const list = options
|
||||||
|
.map((o) => (o.type === "more" ? `- ${o.idx}) ${o.name}` : `- ${o.idx}) ${o.name}`))
|
||||||
|
.join("\n");
|
||||||
|
const question = `¿Cuál de estos querés?\n${list}\n\nRespondé con el número.`;
|
||||||
|
const pending = {
|
||||||
|
candidates: cands,
|
||||||
|
options,
|
||||||
|
candidate_offset: off,
|
||||||
|
page_size: size,
|
||||||
|
base_idx: baseIdx,
|
||||||
|
has_more: hasMore,
|
||||||
|
next_candidate_offset: off + size,
|
||||||
|
next_base_idx: baseIdx + size + (hasMore ? 1 : 0),
|
||||||
|
};
|
||||||
|
return { question, pending, options, hasMore };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePendingSelection({ text, nlu, pending }) {
|
||||||
|
if (!pending?.candidates?.length) return { kind: "none" };
|
||||||
|
|
||||||
|
if (isShowMoreRequest(text)) {
|
||||||
|
const { question, pending: nextPending } = buildPagedOptions({
|
||||||
|
candidates: pending.candidates,
|
||||||
|
candidateOffset: pending.next_candidate_offset ?? ((pending.candidate_offset || 0) + (pending.page_size || 9)),
|
||||||
|
baseIdx: pending.next_base_idx ?? ((pending.base_idx || 1) + (pending.page_size || 9) + 1),
|
||||||
|
pageSize: pending.page_size || 9,
|
||||||
|
});
|
||||||
|
return { kind: "more", question, pending: nextPending };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx =
|
||||||
|
(nlu?.entities?.selection?.type === "index" ? parseInt(String(nlu.entities.selection.value), 10) : null) ??
|
||||||
|
parseIndexSelection(text);
|
||||||
|
if (idx && Array.isArray(pending.options)) {
|
||||||
|
const opt = pending.options.find((o) => o.idx === idx);
|
||||||
|
if (opt?.type === "more") return { kind: "more", question: null, pending };
|
||||||
|
if (opt?.woo_product_id) {
|
||||||
|
const chosen = pending.candidates.find((c) => c.woo_product_id === opt.woo_product_id) || null;
|
||||||
|
if (chosen) return { kind: "chosen", chosen };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selText = nlu?.entities?.selection?.type === "text" ? String(nlu.entities.selection.value || "").trim() : null;
|
||||||
|
const q = selText || nlu?.entities?.product_query || null;
|
||||||
|
if (q) {
|
||||||
|
const scored = pending.candidates
|
||||||
|
.map((c) => ({ c, s: scoreTextMatch(q, c?.name) }))
|
||||||
|
.sort((a, b) => b.s - a.s);
|
||||||
|
if (scored[0]?.s >= 0.6 && (!scored[1] || scored[0].s - scored[1].s >= 0.2)) {
|
||||||
|
return { kind: "chosen", chosen: scored[0].c };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "ask" };
|
||||||
|
}
|
||||||
|
|
||||||
51
src/modules/3-turn-engine/turnEngineV3.units.js
Normal file
51
src/modules/3-turn-engine/turnEngineV3.units.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export function unitAskFor(displayUnit) {
|
||||||
|
if (displayUnit === "unit") return "¿Cuántas unidades querés?";
|
||||||
|
if (displayUnit === "g") return "¿Cuántos gramos querés?";
|
||||||
|
return "¿Cuántos kilos querés?";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unitDisplay(unit) {
|
||||||
|
if (unit === "unit") return "unidades";
|
||||||
|
if (unit === "g") return "gramos";
|
||||||
|
return "kilos";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferDefaultUnit({ name, categories }) {
|
||||||
|
const n = String(name || "").toLowerCase();
|
||||||
|
const cats = Array.isArray(categories) ? categories : [];
|
||||||
|
const hay = (re) =>
|
||||||
|
cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n);
|
||||||
|
if (
|
||||||
|
hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)
|
||||||
|
) {
|
||||||
|
return "unit";
|
||||||
|
}
|
||||||
|
return "kg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUnit(unit) {
|
||||||
|
if (!unit) return null;
|
||||||
|
const u = String(unit).toLowerCase();
|
||||||
|
if (u === "kg" || u === "kilo" || u === "kilos") return "kg";
|
||||||
|
if (u === "g" || u === "gramo" || u === "gramos") return "g";
|
||||||
|
if (u === "unidad" || u === "unidades" || u === "unit") return "unit";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQuantity({ quantity, unit, displayUnit }) {
|
||||||
|
if (quantity == null || !Number.isFinite(Number(quantity)) || Number(quantity) <= 0) return null;
|
||||||
|
const q = Number(quantity);
|
||||||
|
const u = normalizeUnit(unit) || (displayUnit === "unit" ? "unit" : displayUnit === "g" ? "g" : "kg");
|
||||||
|
if (u === "unit") {
|
||||||
|
return { quantity: Math.round(q), unit: "unit", display_quantity: Math.round(q), display_unit: "unit" };
|
||||||
|
}
|
||||||
|
if (u === "g") return { quantity: Math.round(q), unit: "g", display_quantity: Math.round(q), display_unit: "g" };
|
||||||
|
// kg -> gramos enteros
|
||||||
|
return {
|
||||||
|
quantity: Math.round(q * 1000),
|
||||||
|
unit: "g",
|
||||||
|
display_unit: "kg",
|
||||||
|
display_quantity: q,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -146,6 +146,35 @@ export async function searchSnapshotItems({ tenantId, q = "", limit = 12 }) {
|
|||||||
const query = String(q || "").trim();
|
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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user