From a489ec66a2be1d2951161d4e76013dc5e9cb75e5 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:51:33 -0300 Subject: [PATCH] modularizado de prompts --- .cursor/debug.log | 72 --- .../20260125100000_prompt_templates.sql | 50 ++ .../20260125110000_human_takeovers.sql | 56 +++ .../20260125120000_tenant_settings.sql | 58 +++ public/app.js | 3 + public/components/ops-shell.js | 77 +++ public/components/prompts-crud.js | 471 ++++++++++++++++++ public/components/settings-crud.js | 371 ++++++++++++++ public/components/takeovers-crud.js | 409 +++++++++++++++ public/lib/api.js | 80 +++ public/lib/router.js | 6 + src/app.js | 3 +- src/modules/0-ui/controllers/prompts.js | 158 ++++++ src/modules/0-ui/controllers/settings.js | 36 ++ src/modules/0-ui/controllers/takeovers.js | 126 +++++ src/modules/0-ui/db/promptsRepo.js | 183 +++++++ src/modules/0-ui/db/settingsRepo.js | 174 +++++++ src/modules/0-ui/db/takeoverRepo.js | 182 +++++++ src/modules/0-ui/handlers/prompts.js | 258 ++++++++++ src/modules/0-ui/handlers/settings.js | 114 +++++ src/modules/0-ui/handlers/takeovers.js | 246 +++++++++ src/modules/1-intake/routes/simulator.js | 24 + src/modules/2-identity/services/pipeline.js | 20 +- src/modules/3-turn-engine/fsm.js | 44 +- .../3-turn-engine/nlu/defaults/browse.txt | 73 +++ .../3-turn-engine/nlu/defaults/greeting.txt | 23 + .../3-turn-engine/nlu/defaults/orders.txt | 98 ++++ .../3-turn-engine/nlu/defaults/payment.txt | 60 +++ .../3-turn-engine/nlu/defaults/router.txt | 33 ++ .../3-turn-engine/nlu/defaults/shipping.txt | 64 +++ .../3-turn-engine/nlu/humanFallback.js | 164 ++++++ src/modules/3-turn-engine/nlu/index.js | 189 +++++++ src/modules/3-turn-engine/nlu/promptLoader.js | 204 ++++++++ src/modules/3-turn-engine/nlu/router.js | 175 +++++++ src/modules/3-turn-engine/nlu/schemas.js | 283 +++++++++++ .../3-turn-engine/nlu/specialists/browse.js | 170 +++++++ .../3-turn-engine/nlu/specialists/greeting.js | 100 ++++ .../3-turn-engine/nlu/specialists/orders.js | 162 ++++++ .../3-turn-engine/nlu/specialists/payment.js | 135 +++++ .../3-turn-engine/nlu/specialists/shipping.js | 157 ++++++ src/modules/3-turn-engine/stateHandlers.js | 124 +++++ src/modules/3-turn-engine/turnEngineV3.js | 45 +- src/modules/4-woo-orders/wooOrders.js | 17 +- 43 files changed, 5408 insertions(+), 89 deletions(-) delete mode 100644 .cursor/debug.log create mode 100644 db/migrations/20260125100000_prompt_templates.sql create mode 100644 db/migrations/20260125110000_human_takeovers.sql create mode 100644 db/migrations/20260125120000_tenant_settings.sql create mode 100644 public/components/prompts-crud.js create mode 100644 public/components/settings-crud.js create mode 100644 public/components/takeovers-crud.js create mode 100644 src/modules/0-ui/controllers/prompts.js create mode 100644 src/modules/0-ui/controllers/settings.js create mode 100644 src/modules/0-ui/controllers/takeovers.js create mode 100644 src/modules/0-ui/db/promptsRepo.js create mode 100644 src/modules/0-ui/db/settingsRepo.js create mode 100644 src/modules/0-ui/db/takeoverRepo.js create mode 100644 src/modules/0-ui/handlers/prompts.js create mode 100644 src/modules/0-ui/handlers/settings.js create mode 100644 src/modules/0-ui/handlers/takeovers.js create mode 100644 src/modules/3-turn-engine/nlu/defaults/browse.txt create mode 100644 src/modules/3-turn-engine/nlu/defaults/greeting.txt create mode 100644 src/modules/3-turn-engine/nlu/defaults/orders.txt create mode 100644 src/modules/3-turn-engine/nlu/defaults/payment.txt create mode 100644 src/modules/3-turn-engine/nlu/defaults/router.txt create mode 100644 src/modules/3-turn-engine/nlu/defaults/shipping.txt create mode 100644 src/modules/3-turn-engine/nlu/humanFallback.js create mode 100644 src/modules/3-turn-engine/nlu/index.js create mode 100644 src/modules/3-turn-engine/nlu/promptLoader.js create mode 100644 src/modules/3-turn-engine/nlu/router.js create mode 100644 src/modules/3-turn-engine/nlu/schemas.js create mode 100644 src/modules/3-turn-engine/nlu/specialists/browse.js create mode 100644 src/modules/3-turn-engine/nlu/specialists/greeting.js create mode 100644 src/modules/3-turn-engine/nlu/specialists/orders.js create mode 100644 src/modules/3-turn-engine/nlu/specialists/payment.js create mode 100644 src/modules/3-turn-engine/nlu/specialists/shipping.js diff --git a/.cursor/debug.log b/.cursor/debug.log deleted file mode 100644 index 5651951..0000000 --- a/.cursor/debug.log +++ /dev/null @@ -1,72 +0,0 @@ -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"39326","categories":["Carnes > Vacuna"],"sellUnit":"kg"},"timestamp":1768774472072,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/39326","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774472088,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Carnes > Vacuna"],"normalizedNames":["carnes","vacuna"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774472886,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Carnes > Vacuna"],"foundCount":2,"found":[{"id":109,"name":"Carnes"},{"id":115,"name":"Vacuna"}]},"timestamp":1768774472887,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":109},{"id":115}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774472888,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"39326","categories":[],"sellUnit":"kg"},"timestamp":1768774473232,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/39326","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774473235,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774473236,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"39326","responseCategories":[{"id":109,"name":"Carnes"},{"id":115,"name":"Vacuna"}],"status":"success"},"timestamp":1768774473657,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"39326","responseCategories":[{"id":109,"name":"Carnes"},{"id":115,"name":"Vacuna"}],"status":"success"},"timestamp":1768774473983,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"39326","categories":[],"sellUnit":"kg"},"timestamp":1768774474525,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/39326","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774474527,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774474527,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"39326","responseCategories":[{"id":109,"name":"Carnes"},{"id":115,"name":"Vacuna"}],"status":"success"},"timestamp":1768774475208,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"39326","categories":[],"sellUnit":"kg"},"timestamp":1768774481413,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/39326","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774481415,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774481415,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"39326","responseCategories":[{"id":109,"name":"Carnes"},{"id":115,"name":"Vacuna"}],"status":"success"},"timestamp":1768774482123,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3253","categories":["Quesos","Carnes > Achuras"],"sellUnit":"kg"},"timestamp":1768774662226,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3253","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774662231,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Quesos","Carnes > Achuras"],"normalizedNames":["quesos","carnes","achuras"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774663022,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Quesos","Carnes > Achuras"],"foundCount":3,"found":[{"id":110,"name":"Achuras"},{"id":109,"name":"Carnes"},{"id":120,"name":"Quesos"}]},"timestamp":1768774663023,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":110},{"id":109},{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774663023,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3253","responseCategories":[{"id":109,"name":"Carnes"},{"id":110,"name":"Achuras"},{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774663803,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3256","categories":["Quesos","Carnes > Achuras"],"sellUnit":"kg"},"timestamp":1768774663825,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3256","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774663827,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Quesos","Carnes > Achuras"],"normalizedNames":["quesos","carnes","achuras"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774664616,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Quesos","Carnes > Achuras"],"foundCount":3,"found":[{"id":110,"name":"Achuras"},{"id":109,"name":"Carnes"},{"id":120,"name":"Quesos"}]},"timestamp":1768774664616,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":110},{"id":109},{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774664617,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3256","responseCategories":[{"id":109,"name":"Carnes"},{"id":110,"name":"Achuras"},{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774665336,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3251","categories":["Fiambreria y Charcuteria de autor","Quesos","Carnes > Achuras"],"sellUnit":"kg"},"timestamp":1768774665354,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3251","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774665357,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Fiambreria y Charcuteria de autor","Quesos","Carnes > Achuras"],"normalizedNames":["fiambreria y charcuteria de autor","quesos","carnes","achuras"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774666044,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Fiambreria y Charcuteria de autor","Quesos","Carnes > Achuras"],"foundCount":4,"found":[{"id":110,"name":"Achuras"},{"id":109,"name":"Carnes"},{"id":117,"name":"Fiambreria y Charcuteria de autor"},{"id":120,"name":"Quesos"}]},"timestamp":1768774666045,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":110},{"id":109},{"id":117},{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774666045,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3251","responseCategories":[{"id":109,"name":"Carnes"},{"id":110,"name":"Achuras"},{"id":117,"name":"Fiambreria y Charcuteria de autor"},{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774666798,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3253","categories":["Quesos"],"sellUnit":"kg"},"timestamp":1768774718170,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3253","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774718173,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Quesos"],"normalizedNames":["quesos"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774718887,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Quesos"],"foundCount":1,"found":[{"id":120,"name":"Quesos"}]},"timestamp":1768774718887,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774718887,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3253","responseCategories":[{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774719630,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3256","categories":["Quesos"],"sellUnit":"kg"},"timestamp":1768774722058,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3256","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774722061,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Quesos"],"normalizedNames":["quesos"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774722779,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Quesos"],"foundCount":1,"found":[{"id":120,"name":"Quesos"}]},"timestamp":1768774722780,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774722780,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3256","categories":["Quesos"],"sellUnit":"kg"},"timestamp":1768774723054,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3256","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774723055,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3256","responseCategories":[{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774723469,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Quesos"],"normalizedNames":["quesos"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774723821,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Quesos"],"foundCount":1,"found":[{"id":120,"name":"Quesos"}]},"timestamp":1768774723822,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774723822,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3256","responseCategories":[{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774724564,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3253","categories":["Quesos"],"sellUnit":"kg"},"timestamp":1768774728111,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3253","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774728113,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Quesos"],"normalizedNames":["quesos"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774728877,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Quesos"],"foundCount":1,"found":[{"id":120,"name":"Quesos"}]},"timestamp":1768774728877,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774728877,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3253","responseCategories":[{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774729591,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3251","categories":["Fiambreria y Charcuteria de autor","Quesos"],"sellUnit":"kg"},"timestamp":1768774733211,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3251","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774733215,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Fiambreria y Charcuteria de autor","Quesos"],"normalizedNames":["fiambreria y charcuteria de autor","quesos"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774733981,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Fiambreria y Charcuteria de autor","Quesos"],"foundCount":2,"found":[{"id":117,"name":"Fiambreria y Charcuteria de autor"},{"id":120,"name":"Quesos"}]},"timestamp":1768774733981,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":117},{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774733981,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3251","responseCategories":[{"id":117,"name":"Fiambreria y Charcuteria de autor"},{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774734709,"sessionId":"debug-session","hypothesisId":"D,E"} -{"location":"wooSnapshot.js:pushProductToWoo:entry","message":"Push entrada","data":{"wooProductId":"3251","categories":["Fiambreria y Charcuteria de autor","Quesos"],"sellUnit":"kg"},"timestamp":1768774735556,"sessionId":"debug-session","hypothesisId":"A,B"} -{"location":"wooSnapshot.js:pushProductToWoo:url","message":"URL construida","data":{"url":"https://piaf.floda.dev/wp-json/wc/v3/products/3251","base":"https://piaf.floda.dev/wp-json/wc/v3"},"timestamp":1768774735558,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"wooSnapshot.js:fetchWooCategoriesByNames","message":"Categorias en Woo","data":{"searchingFor":["Fiambreria y Charcuteria de autor","Quesos"],"normalizedNames":["fiambreria y charcuteria de autor","quesos"],"wooCategoriesCount":50,"wooCategories":[{"id":96,"name":"Aceites acetos y vinagres","slug":"aceites-acetos-y-vinagres"},{"id":99,"name":"Aceitesacetos y vinagres","slug":"aceitesacetos-y-vinagres"},{"id":95,"name":"Aceitunas","slug":"aceitunas"},{"id":110,"name":"Achuras","slug":"achuras"},{"id":85,"name":"Almacén","slug":"almacen"},{"id":87,"name":"Asado","slug":"asado"},{"id":97,"name":"Bebidas","slug":"bebidas"},{"id":138,"name":"Cabernet","slug":"cabernet"},{"id":134,"name":"Calchaquies","slug":"calchaquies"},{"id":109,"name":"Carnes","slug":"carnes"},{"id":111,"name":"Cerdo","slug":"cerdo"},{"id":126,"name":"Cerveza","slug":"cerveza"},{"id":98,"name":"Con alcohol","slug":"con-alcohol"},{"id":90,"name":"Condimentos","slug":"condimentos"},{"id":93,"name":"Conservas","slug":"conservas"},{"id":112,"name":"Cordero","slug":"cordero"},{"id":92,"name":"Delicatessen","slug":"delicatessen"},{"id":105,"name":"Dips","slug":"dips"},{"id":104,"name":"Dulces","slug":"dulces"},{"id":118,"name":"Embutidos","slug":"embutidos-rebozados"},{"id":113,"name":"Exóticas","slug":"exoticas"},{"id":117,"name":"Fiambreria y Charcuteria de autor","slug":"charcuteria"},{"id":121,"name":"Frutos secos","slug":"frutos-secos"},{"id":116,"name":"Hamburguesas","slug":"hamburguesas"},{"id":86,"name":"Huevos","slug":"huevos"},{"id":135,"name":"Low cost","slug":"low-cost"},{"id":127,"name":"Madurada","slug":"madurada"},{"id":136,"name":"Malbec","slug":"malbec"},{"id":132,"name":"Mendoza","slug":"mendoza"},{"id":103,"name":"Mermeladas","slug":"mermeladas"},{"id":100,"name":"Miel","slug":"miel"},{"id":88,"name":"Panes y Harinas","slug":"panes-y-harinas"},{"id":101,"name":"Pastas y arroces","slug":"pastas-y-arroces"},{"id":131,"name":"Patagonia","slug":"patagonia"},{"id":94,"name":"Pepinos","slug":"pepinos"},{"id":119,"name":"Pescados","slug":"pescados"},{"id":137,"name":"Pinot Noir","slug":"pinot-noir"},{"id":114,"name":"Pollo","slug":"pollo"},{"id":84,"name":"Proveeduría","slug":"proveeduria"},{"id":120,"name":"Quesos","slug":"quesos"},{"id":15,"name":"Rebozados","slug":"uncategorized"},{"id":89,"name":"Sal pimienta y especias","slug":"sal-pimienta-y-especias"},{"id":91,"name":"Salsas","slug":"salsas"},{"id":130,"name":"Selección Malbec","slug":"seleccion-malbec"},{"id":133,"name":"Selección Pinot","slug":"seleccion-pinot"},{"id":129,"name":"Seleccionados","slug":"seleccionados"},{"id":108,"name":"Sin alcohol","slug":"sin-alcohol"},{"id":102,"name":"Snacks","slug":"snacks"},{"id":115,"name":"Vacuna","slug":"vacuna"},{"id":125,"name":"Vinos","slug":"vinos"}]},"timestamp":1768774736395,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"wooSnapshot.js:pushProductToWoo:cats","message":"Categorias mapeadas","data":{"requested":["Fiambreria y Charcuteria de autor","Quesos"],"foundCount":2,"found":[{"id":117,"name":"Fiambreria y Charcuteria de autor"},{"id":120,"name":"Quesos"}]},"timestamp":1768774736395,"sessionId":"debug-session","hypothesisId":"B,C"} -{"location":"wooSnapshot.js:pushProductToWoo:payload","message":"Payload final","data":{"updatePayload":{"categories":[{"id":117},{"id":120}],"meta_data":[{"key":"_sell_unit_override","value":"kg"}]},"isEmpty":false},"timestamp":1768774736395,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"wooSnapshot.js:pushProductToWoo:response","message":"Respuesta Woo","data":{"wooProductId":"3251","responseCategories":[{"id":117,"name":"Fiambreria y Charcuteria de autor"},{"id":120,"name":"Quesos"}],"status":"success"},"timestamp":1768774737141,"sessionId":"debug-session","hypothesisId":"D,E"} diff --git a/db/migrations/20260125100000_prompt_templates.sql b/db/migrations/20260125100000_prompt_templates.sql new file mode 100644 index 0000000..3bf4cce --- /dev/null +++ b/db/migrations/20260125100000_prompt_templates.sql @@ -0,0 +1,50 @@ +-- migrate:up + +-- Tabla para almacenar prompts editables por tenant con versionado +CREATE TABLE prompt_templates ( + id SERIAL PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + prompt_key VARCHAR(50) NOT NULL, -- 'router', 'greeting', 'orders', 'shipping', 'payment', 'browse' + content TEXT NOT NULL, + model VARCHAR(50) DEFAULT 'gpt-4-turbo', + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by TEXT, -- email o identificador del usuario que creó el prompt + + CONSTRAINT uq_prompt_version UNIQUE(tenant_id, prompt_key, version) +); + +-- Índice para búsqueda rápida del prompt activo +CREATE INDEX idx_prompt_active ON prompt_templates(tenant_id, prompt_key) +WHERE is_active = true; + +-- Índice para listar versiones +CREATE INDEX idx_prompt_versions ON prompt_templates(tenant_id, prompt_key, version DESC); + +-- Función para auto-incrementar versión al insertar +CREATE OR REPLACE FUNCTION increment_prompt_version() +RETURNS TRIGGER AS $$ +BEGIN + -- Calcular siguiente versión + NEW.version := COALESCE( + (SELECT MAX(version) + 1 FROM prompt_templates + WHERE tenant_id = NEW.tenant_id AND prompt_key = NEW.prompt_key), + 1 + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger para auto-versión +CREATE TRIGGER prompt_version_trigger +BEFORE INSERT ON prompt_templates +FOR EACH ROW EXECUTE FUNCTION increment_prompt_version(); + +-- migrate:down + +DROP TRIGGER IF EXISTS prompt_version_trigger ON prompt_templates; +DROP FUNCTION IF EXISTS increment_prompt_version(); +DROP INDEX IF EXISTS idx_prompt_versions; +DROP INDEX IF EXISTS idx_prompt_active; +DROP TABLE IF EXISTS prompt_templates; diff --git a/db/migrations/20260125110000_human_takeovers.sql b/db/migrations/20260125110000_human_takeovers.sql new file mode 100644 index 0000000..43a827b --- /dev/null +++ b/db/migrations/20260125110000_human_takeovers.sql @@ -0,0 +1,56 @@ +-- migrate:up + +-- Tabla para manejar conversaciones que requieren intervención humana +CREATE TABLE human_takeovers ( + id SERIAL PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + chat_id TEXT NOT NULL, + + -- Info del takeover + pending_query TEXT NOT NULL, -- La query/producto que no se encontró + reason TEXT NOT NULL DEFAULT 'product_not_found', -- Razón del takeover + context_snapshot JSONB, -- Snapshot del contexto al momento del takeover + + -- Estado + status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'responded', 'cancelled' + + -- Respuesta del humano + human_response TEXT, -- Texto de la respuesta + responded_at TIMESTAMPTZ, + responded_by TEXT, -- Email/ID del usuario que respondió + + -- Metadatos + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Índices + CONSTRAINT valid_status CHECK (status IN ('pending', 'responded', 'cancelled')) +); + +-- Índice para buscar takeovers pendientes por tenant +CREATE INDEX idx_takeovers_pending ON human_takeovers(tenant_id, status, created_at DESC) +WHERE status = 'pending'; + +-- Índice para buscar por chat_id +CREATE INDEX idx_takeovers_chat ON human_takeovers(tenant_id, chat_id, created_at DESC); + +-- Función para actualizar updated_at +CREATE OR REPLACE FUNCTION update_takeover_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER takeover_updated_trigger +BEFORE UPDATE ON human_takeovers +FOR EACH ROW EXECUTE FUNCTION update_takeover_timestamp(); + +-- migrate:down + +DROP TRIGGER IF EXISTS takeover_updated_trigger ON human_takeovers; +DROP FUNCTION IF EXISTS update_takeover_timestamp(); +DROP INDEX IF EXISTS idx_takeovers_chat; +DROP INDEX IF EXISTS idx_takeovers_pending; +DROP TABLE IF EXISTS human_takeovers; diff --git a/db/migrations/20260125120000_tenant_settings.sql b/db/migrations/20260125120000_tenant_settings.sql new file mode 100644 index 0000000..96a34b7 --- /dev/null +++ b/db/migrations/20260125120000_tenant_settings.sql @@ -0,0 +1,58 @@ +-- migrate:up + +-- Tabla para configuración del tenant (variables de prompts, horarios, etc.) +CREATE TABLE tenant_settings ( + id SERIAL PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Info básica del negocio + store_name VARCHAR(100) NOT NULL DEFAULT 'Mi Negocio', + bot_name VARCHAR(50) NOT NULL DEFAULT 'Piaf', + store_address TEXT, + store_phone VARCHAR(50), + + -- Horarios de delivery + delivery_enabled BOOLEAN DEFAULT true, + delivery_days VARCHAR(50) DEFAULT 'lun,mar,mie,jue,vie,sab', -- días separados por coma + delivery_hours_start TIME DEFAULT '09:00', + delivery_hours_end TIME DEFAULT '18:00', + delivery_min_order DECIMAL(10,2) DEFAULT 0, + + -- Horarios de retiro en tienda + pickup_enabled BOOLEAN DEFAULT true, + pickup_days VARCHAR(50) DEFAULT 'lun,mar,mie,jue,vie,sab', + pickup_hours_start TIME DEFAULT '08:00', + pickup_hours_end TIME DEFAULT '20:00', + + -- Metadatos + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Solo un registro por tenant + CONSTRAINT uq_tenant_settings UNIQUE(tenant_id) +); + +-- Trigger para actualizar updated_at +CREATE OR REPLACE FUNCTION update_tenant_settings_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tenant_settings_updated_trigger +BEFORE UPDATE ON tenant_settings +FOR EACH ROW EXECUTE FUNCTION update_tenant_settings_timestamp(); + +-- Crear registro default para tenants existentes +INSERT INTO tenant_settings (tenant_id, store_name, bot_name) +SELECT id, 'Mi Carnicería', 'Piaf' +FROM tenants +ON CONFLICT (tenant_id) DO NOTHING; + +-- migrate:down + +DROP TRIGGER IF EXISTS tenant_settings_updated_trigger ON tenant_settings; +DROP FUNCTION IF EXISTS update_tenant_settings_timestamp(); +DROP TABLE IF EXISTS tenant_settings; diff --git a/public/app.js b/public/app.js index 5dd2225..633fd08 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,9 @@ import "./components/recommendations-crud.js"; import "./components/quantities-crud.js"; import "./components/orders-crud.js"; import "./components/test-panel.js"; +import "./components/prompts-crud.js"; +import "./components/takeovers-crud.js"; +import "./components/settings-crud.js"; import { connectSSE } from "./lib/sse.js"; import { initRouter } from "./lib/router.js"; diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js index ab878ba..bfeeb59 100644 --- a/public/components/ops-shell.js +++ b/public/components/ops-shell.js @@ -1,5 +1,6 @@ import { emit, on } from "../lib/bus.js"; import { navigateToView, navigateToItem } from "../lib/router.js"; +import { api } from "../lib/api.js"; class OpsShell extends HTMLElement { constructor() { @@ -7,6 +8,7 @@ class OpsShell extends HTMLElement { this.attachShadow({ mode: "open" }); this._currentView = "chat"; this._currentParams = {}; + this._takeoverCount = 0; this.shadowRoot.innerHTML = ` + +
+
+
Prompts del Sistema
+
+
Cargando...
+
+
+ +
+
Editor de Prompt
+
+
Selecciona un prompt para editarlo
+
+
+
+ `; + } + + connectedCallback() { + this.load(); + + // Refrescar settings cuando se vuelve a esta vista (por si cambiaron en Config) + this._unsubRouter = on("router:viewChanged", ({ view }) => { + if (view === "prompts") { + this.refreshSettings(); + } + }); + } + + disconnectedCallback() { + this._unsubRouter?.(); + } + + async refreshSettings() { + try { + const settings = await api.getSettings(); + this.currentSettings = { + store_name: settings.store_name || "", + store_hours: this.formatStoreHours(settings), + store_address: settings.store_address || "", + store_phone: settings.store_phone || "", + bot_name: settings.bot_name || "", + current_date: new Date().toLocaleDateString("es-AR"), + customer_name: "(nombre del cliente)", + state: "(estado actual)", + }; + // Re-renderizar el form si hay uno seleccionado + if (this.selected) { + this.renderForm(); + } + } catch (e) { + console.debug("Error refreshing settings:", e); + } + } + + async load() { + this.loading = true; + this.renderList(); + + try { + // Cargar prompts y settings en paralelo + const [data, settings] = await Promise.all([ + api.prompts(), + api.getSettings().catch(() => ({})), + ]); + + this.items = data.items || []; + this.availableVariables = data.available_variables || []; + this.availableModels = data.available_models || []; + + // Mapear settings a variables + this.currentSettings = { + store_name: settings.store_name || "", + store_hours: this.formatStoreHours(settings), + store_address: settings.store_address || "", + store_phone: settings.store_phone || "", + bot_name: settings.bot_name || "", + current_date: new Date().toLocaleDateString("es-AR"), + customer_name: "(nombre del cliente)", + state: "(estado actual)", + }; + + this.loading = false; + this.renderList(); + } catch (e) { + console.error("Error loading prompts:", e); + this.items = []; + this.loading = false; + this.renderList(); + } + } + + formatStoreHours(settings) { + if (!settings.pickup_days) return ""; + + // Mapeo de días cortos a nombres legibles + const dayNames = { + lun: "Lun", mar: "Mar", mie: "Mié", jue: "Jue", + vie: "Vie", sab: "Sáb", dom: "Dom" + }; + + const days = settings.pickup_days.split(",").map(d => dayNames[d.trim()] || d).join(", "); + const start = (settings.pickup_hours_start || "08:00").slice(0, 5); + const end = (settings.pickup_hours_end || "20:00").slice(0, 5); + + return `${days} de ${start} a ${end}`; + } + + renderList() { + const list = this.shadowRoot.getElementById("list"); + + if (this.loading) { + list.innerHTML = `
Cargando...
`; + return; + } + + if (!this.items.length) { + list.innerHTML = `
No se encontraron prompts
`; + return; + } + + list.innerHTML = ""; + for (const item of this.items) { + const el = document.createElement("div"); + el.className = "item" + (this.selected?.prompt_key === item.prompt_key ? " active" : ""); + + const label = PROMPT_LABELS[item.prompt_key] || item.prompt_key; + const statusClass = item.is_default ? "default" : "custom"; + const statusText = item.is_default ? "Default" : `v${item.version}`; + + el.innerHTML = ` +
${label}
+
+ ${statusText} + ${item.model ? ` | ${item.model}` : ""} +
+ `; + + el.onclick = () => this.selectPrompt(item); + list.appendChild(el); + } + } + + async selectPrompt(item) { + this.selected = item; + this.testResult = null; + this.renderList(); + + // Cargar detalles con versiones + try { + const details = await api.getPrompt(item.prompt_key); + this.selected = { ...item, ...details.current }; + this.versions = details.versions || []; + this.availableVariables = details.available_variables || this.availableVariables; + this.availableModels = details.available_models || this.availableModels; + this.renderForm(); + } catch (e) { + console.error("Error loading prompt details:", e); + this.renderForm(); + } + } + + renderForm() { + const form = this.shadowRoot.getElementById("form"); + const title = this.shadowRoot.getElementById("formTitle"); + + if (!this.selected) { + title.textContent = "Editor de Prompt"; + form.innerHTML = `
Selecciona un prompt para editarlo
`; + return; + } + + const label = PROMPT_LABELS[this.selected.prompt_key] || this.selected.prompt_key; + title.textContent = `Editar: ${label}`; + + const content = this.selected.content || ""; + const model = this.selected.model || "gpt-4-turbo"; + + form.innerHTML = ` +
+
+ + +
+
+ + +
+
+ +
+ + +
+ Variables disponibles (click para insertar): +
+ ${this.availableVariables.map(v => { + const key = typeof v === 'string' ? v : v.key; + const desc = typeof v === 'string' ? '' : (v.description || ''); + const value = this.currentSettings[key] || ''; + const displayValue = value ? `= ${value}` : '(vacío)'; + return ` + + ${this.escapeHtml(displayValue)} + `; + }).join("")} +
+
+
+ + ${this.versions.length > 0 ? ` +
+ +
+ ${this.versions.map(v => ` +
+ v${v.version} ${v.is_active ? "(activa)" : ""} + ${this.formatDate(v.created_at)} + ${!v.is_active ? `` : ""} +
+ `).join("")} +
+
+ ` : ""} + +
+ + +
+ + + `; + + // Event listeners + this.shadowRoot.getElementById("saveBtn").onclick = () => this.save(); + this.shadowRoot.getElementById("resetBtn").onclick = () => this.reset(); + this.shadowRoot.getElementById("testBtn").onclick = () => this.toggleTestSection(); + this.shadowRoot.getElementById("runTestBtn").onclick = () => this.runTest(); + + // Variable buttons + this.shadowRoot.querySelectorAll(".var-btn").forEach(btn => { + btn.onclick = () => this.insertVariable(btn.dataset.var); + }); + + // Version restore buttons + this.shadowRoot.querySelectorAll(".versions-list button").forEach(btn => { + btn.onclick = () => this.rollback(parseInt(btn.dataset.version, 10)); + }); + } + + escapeHtml(str) { + return (str || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + formatDate(dateStr) { + if (!dateStr) return ""; + const d = new Date(dateStr); + return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + } + + insertVariable(varName) { + const textarea = this.shadowRoot.getElementById("contentInput"); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + const insertion = `{{${varName}}}`; + textarea.value = text.slice(0, start) + insertion + text.slice(end); + textarea.selectionStart = textarea.selectionEnd = start + insertion.length; + textarea.focus(); + } + + toggleTestSection() { + const section = this.shadowRoot.getElementById("testSection"); + section.style.display = section.style.display === "none" ? "block" : "none"; + } + + async save() { + const content = this.shadowRoot.getElementById("contentInput").value; + const model = this.shadowRoot.getElementById("modelSelect").value; + + if (!content.trim()) { + alert("El contenido no puede estar vacio"); + return; + } + + try { + await api.savePrompt(this.selected.prompt_key, { content, model }); + alert("Prompt guardado correctamente"); + await this.load(); + // Re-seleccionar el prompt actual + const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); + if (updated) this.selectPrompt(updated); + } catch (e) { + console.error("Error saving prompt:", e); + alert("Error guardando: " + (e.message || e)); + } + } + + async reset() { + if (!confirm("Esto desactivara todas las versiones custom y volvera al prompt por defecto. Continuar?")) { + return; + } + + try { + await api.resetPrompt(this.selected.prompt_key); + alert("Prompt reseteado a default"); + await this.load(); + const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); + if (updated) this.selectPrompt(updated); + } catch (e) { + console.error("Error resetting prompt:", e); + alert("Error: " + (e.message || e)); + } + } + + async rollback(version) { + if (!confirm(`Restaurar version ${version}? Se creara una nueva version con ese contenido.`)) { + return; + } + + try { + await api.rollbackPrompt(this.selected.prompt_key, version); + alert("Version restaurada"); + await this.load(); + const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); + if (updated) this.selectPrompt(updated); + } catch (e) { + console.error("Error rolling back:", e); + alert("Error: " + (e.message || e)); + } + } + + async runTest() { + const testMessage = this.shadowRoot.getElementById("testMessage").value; + if (!testMessage.trim()) { + alert("Ingresa un mensaje de prueba"); + return; + } + + const content = this.shadowRoot.getElementById("contentInput").value; + const container = this.shadowRoot.getElementById("testResultContainer"); + container.innerHTML = `
Ejecutando prueba...
`; + + try { + const result = await api.testPrompt(this.selected.prompt_key, { + content, + test_message: testMessage, + store_config: { store_name: "Carniceria Demo", bot_name: "Piaf" }, + }); + + if (result.ok) { + let parsed = result.response; + try { + parsed = JSON.stringify(JSON.parse(result.response), null, 2); + } catch (e) { /* no es JSON */ } + + container.innerHTML = ` +
${this.escapeHtml(parsed)}
+
+ Modelo: ${result.model} | Latencia: ${result.latency_ms}ms | + Tokens: ${result.usage?.total_tokens || "?"} +
+ `; + } else { + container.innerHTML = `
Error: ${result.error || "Unknown"}
`; + } + } catch (e) { + console.error("Error testing prompt:", e); + container.innerHTML = `
Error: ${e.message || e}
`; + } + } +} + +customElements.define("prompts-crud", PromptsCrud); diff --git a/public/components/settings-crud.js b/public/components/settings-crud.js new file mode 100644 index 0000000..00cd54f --- /dev/null +++ b/public/components/settings-crud.js @@ -0,0 +1,371 @@ +import { api } from "../lib/api.js"; + +const DAYS = [ + { id: "lun", label: "Lun" }, + { id: "mar", label: "Mar" }, + { id: "mie", label: "Mié" }, + { id: "jue", label: "Jue" }, + { id: "vie", label: "Vie" }, + { id: "sab", label: "Sáb" }, + { id: "dom", label: "Dom" }, +]; + +class SettingsCrud extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.settings = null; + this.loading = false; + this.saving = false; + + this.shadowRoot.innerHTML = ` + + +
+
+
+
Cargando configuración...
+
+
+ `; + } + + connectedCallback() { + this.load(); + } + + async load() { + this.loading = true; + this.render(); + + try { + this.settings = await api.getSettings(); + this.loading = false; + this.render(); + } catch (e) { + console.error("Error loading settings:", e); + this.loading = false; + this.showError("Error cargando configuración: " + e.message); + } + } + + render() { + const content = this.shadowRoot.getElementById("content"); + + if (this.loading) { + content.innerHTML = `
Cargando configuración...
`; + return; + } + + if (!this.settings) { + content.innerHTML = `
No se pudo cargar la configuración
`; + return; + } + + const s = this.settings; + const deliveryDays = (s.delivery_days || "").split(",").filter(d => d); + const pickupDays = (s.pickup_days || "").split(",").filter(d => d); + + content.innerHTML = ` + +
+
+ + Información del Negocio +
+ +
+
+ + +
Se usa en los mensajes del bot
+
+
+ + +
El asistente virtual
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Delivery (Envío a domicilio) +
+ +
+
+ Delivery habilitado +
+ +
+
+ +
+ ${DAYS.map(d => ` + + `).join("")} +
+
+ +
+ Horario: + + a + + (formato 24hs) +
+ +
+ + +
+
+
+ + +
+
+ + Retiro en Tienda +
+ +
+
+ Retiro en tienda habilitado +
+ +
+
+ +
+ ${DAYS.map(d => ` + + `).join("")} +
+
+ +
+ Horario: + + a + + (formato 24hs) +
+
+
+ +
+ + +
+ `; + + this.setupEventListeners(); + } + + setupEventListeners() { + // Toggle delivery + const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle"); + deliveryToggle?.addEventListener("click", () => { + this.settings.delivery_enabled = !this.settings.delivery_enabled; + this.render(); + }); + + // Toggle pickup + const pickupToggle = this.shadowRoot.getElementById("pickupToggle"); + pickupToggle?.addEventListener("click", () => { + this.settings.pickup_enabled = !this.settings.pickup_enabled; + this.render(); + }); + + // Delivery days + this.shadowRoot.querySelectorAll("#deliveryDays .day-btn").forEach(btn => { + btn.addEventListener("click", () => { + const day = btn.dataset.day; + let days = (this.settings.delivery_days || "").split(",").filter(d => d); + if (days.includes(day)) { + days = days.filter(d => d !== day); + } else { + days.push(day); + } + // Ordenar días + days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b)); + this.settings.delivery_days = days.join(","); + this.render(); + }); + }); + + // Pickup days + this.shadowRoot.querySelectorAll("#pickupDays .day-btn").forEach(btn => { + btn.addEventListener("click", () => { + const day = btn.dataset.day; + let days = (this.settings.pickup_days || "").split(",").filter(d => d); + if (days.includes(day)) { + days = days.filter(d => d !== day); + } else { + days.push(day); + } + days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b)); + this.settings.pickup_days = days.join(","); + this.render(); + }); + }); + + // Save button + this.shadowRoot.getElementById("saveBtn")?.addEventListener("click", () => this.save()); + + // Reset button + this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load()); + } + + async save() { + // Collect form data BEFORE re-rendering + const data = { + store_name: this.shadowRoot.getElementById("storeName")?.value || "", + bot_name: this.shadowRoot.getElementById("botName")?.value || "", + store_address: this.shadowRoot.getElementById("storeAddress")?.value || "", + store_phone: this.shadowRoot.getElementById("storePhone")?.value || "", + delivery_enabled: this.settings.delivery_enabled, + delivery_days: this.settings.delivery_days, + delivery_hours_start: this.shadowRoot.getElementById("deliveryStart")?.value || "09:00", + delivery_hours_end: this.shadowRoot.getElementById("deliveryEnd")?.value || "18:00", + delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0, + pickup_enabled: this.settings.pickup_enabled, + pickup_days: this.settings.pickup_days, + pickup_hours_start: this.shadowRoot.getElementById("pickupStart")?.value || "08:00", + pickup_hours_end: this.shadowRoot.getElementById("pickupEnd")?.value || "20:00", + }; + + // Update settings with form values so they persist through render + this.settings = { ...this.settings, ...data }; + + this.saving = true; + this.render(); + + try { + console.log("[settings-crud] Saving:", data); + const result = await api.saveSettings(data); + console.log("[settings-crud] Save result:", result); + + if (result.ok === false) { + throw new Error(result.message || result.error || "Error desconocido"); + } + + this.settings = result.settings || data; + this.saving = false; + this.showSuccess(result.message || "Configuración guardada correctamente"); + this.render(); + } catch (e) { + console.error("[settings-crud] Error saving settings:", e); + this.saving = false; + this.showError("Error guardando: " + (e.message || e)); + this.render(); + } + } + + escapeHtml(str) { + return (str || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + showSuccess(msg) { + const messages = this.shadowRoot.getElementById("messages"); + messages.innerHTML = `
${msg}
`; + setTimeout(() => { messages.innerHTML = ""; }, 4000); + } + + showError(msg) { + const messages = this.shadowRoot.getElementById("messages"); + messages.innerHTML = `
${msg}
`; + setTimeout(() => { messages.innerHTML = ""; }, 5000); + } +} + +customElements.define("settings-crud", SettingsCrud); diff --git a/public/components/takeovers-crud.js b/public/components/takeovers-crud.js new file mode 100644 index 0000000..4490a2c --- /dev/null +++ b/public/components/takeovers-crud.js @@ -0,0 +1,409 @@ +import { api } from "../lib/api.js"; +import { on } from "../lib/bus.js"; + +class TakeoversCrud extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.items = []; + this.selected = null; + this.loading = false; + this.products = []; + this.pendingCount = 0; + + this.shadowRoot.innerHTML = ` + + +
+
+
+ Takeovers Pendientes + +
+
+
Cargando...
+
+
+ +
+
Responder
+
+
Selecciona un takeover para responder
+
+
+
+ `; + } + + connectedCallback() { + this.load(); + this.loadProducts(); + + // Refresh cuando se recibe evento SSE de nuevo takeover + this._unsubSse = on("sse:takeover", () => this.load()); + } + + disconnectedCallback() { + this._unsubSse?.(); + } + + async load() { + this.loading = true; + this.renderList(); + + try { + const data = await api.takeovers({ limit: 50 }); + this.items = data.items || []; + this.pendingCount = data.pending_count || this.items.length; + this.loading = false; + this.renderList(); + } catch (e) { + console.error("Error loading takeovers:", e); + this.items = []; + this.loading = false; + this.renderList(); + } + } + + async loadProducts() { + try { + const data = await api.products({ limit: 2000 }); + this.products = data.items || []; + } catch (e) { + console.error("Error loading products:", e); + this.products = []; + } + } + + getProductName(id) { + const p = this.products.find(x => x.woo_product_id === id); + return p?.name || `Producto #${id}`; + } + + formatTime(dateStr) { + if (!dateStr) return ""; + const d = new Date(dateStr); + const now = new Date(); + const diffMs = now - d; + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return "Hace un momento"; + if (diffMins < 60) return `Hace ${diffMins} min`; + if (diffMins < 1440) return `Hace ${Math.floor(diffMins / 60)} hs`; + return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + } + + renderList() { + const list = this.shadowRoot.getElementById("list"); + const badge = this.shadowRoot.getElementById("pendingBadge"); + + if (this.pendingCount > 0) { + badge.textContent = this.pendingCount; + badge.style.display = "inline"; + } else { + badge.style.display = "none"; + } + + if (this.loading) { + list.innerHTML = `
Cargando...
`; + return; + } + + if (!this.items.length) { + list.innerHTML = ` +
+ +
No hay takeovers pendientes
+
+ `; + return; + } + + list.innerHTML = ""; + for (const item of this.items) { + const el = document.createElement("div"); + el.className = "item" + (this.selected?.id === item.id ? " active" : ""); + + el.innerHTML = ` +
"${item.pending_query}"
+
${this.getReasonLabel(item.reason)}
+
Chat: ${item.chat_id}
+
${this.formatTime(item.created_at)}
+ `; + + el.onclick = () => this.selectTakeover(item); + list.appendChild(el); + } + } + + getReasonLabel(reason) { + const labels = { + product_not_found: "Producto no encontrado", + low_confidence: "Baja confianza del NLU", + ambiguous: "Consulta ambigua", + }; + return labels[reason] || reason; + } + + async selectTakeover(item) { + this.selected = item; + this.renderList(); + + // Cargar detalles con historial + try { + const details = await api.getTakeover(item.id); + this.selected = { ...item, ...details }; + this.renderForm(); + } catch (e) { + console.error("Error loading takeover details:", e); + this.renderForm(); + } + } + + renderForm() { + const form = this.shadowRoot.getElementById("form"); + + if (!this.selected) { + form.innerHTML = `
Selecciona un takeover para responder
`; + return; + } + + const history = this.selected.conversation_history || this.selected.recent_messages || []; + + form.innerHTML = ` +
+ +
"${this.selected.pending_query}"
+
+ + ${history.length > 0 ? ` +
+ +
+ ${history.slice(-10).map(m => ` +
+
${m.role === "user" ? "Cliente" : "Bot"}
+
${this.escapeHtml((m.content || "").slice(0, 300))}
+
+ `).join("")} +
+
+ ` : ""} + +
+ + +
+ +
+

Agregar Alias (opcional)

+
+ + +
+ +
+ +
+ + +
+ `; + + // Event listeners + this.shadowRoot.getElementById("respondBtn").onclick = () => this.respond(); + this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel(); + + const addAliasCheck = this.shadowRoot.getElementById("addAliasCheck"); + const aliasSection = this.shadowRoot.getElementById("aliasProductSection"); + addAliasCheck.onchange = () => { + aliasSection.style.display = addAliasCheck.checked ? "block" : "none"; + }; + + this.setupProductSelector(); + } + + escapeHtml(str) { + return (str || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + setupProductSelector() { + const searchInput = this.shadowRoot.getElementById("productSearch"); + const dropdown = this.shadowRoot.getElementById("productDropdown"); + + if (!searchInput || !dropdown) return; + + this._selectedProductId = null; + + const renderDropdown = (query) => { + const q = (query || "").toLowerCase().trim(); + let filtered = this.products; + if (q) { + filtered = filtered.filter(p => p.name.toLowerCase().includes(q)); + } + filtered = filtered.slice(0, 30); + + if (!filtered.length) { + dropdown.classList.remove("open"); + return; + } + + dropdown.innerHTML = filtered.map(p => ` +
+ ${p.name} + $${p.price || 0} +
+ `).join(""); + + dropdown.querySelectorAll(".product-option").forEach(opt => { + opt.onclick = () => { + this._selectedProductId = parseInt(opt.dataset.id, 10); + searchInput.value = this.getProductName(this._selectedProductId); + dropdown.classList.remove("open"); + }; + }); + + dropdown.classList.add("open"); + }; + + searchInput.oninput = () => { + this._selectedProductId = null; + clearTimeout(this._searchTimer); + this._searchTimer = setTimeout(() => renderDropdown(searchInput.value), 150); + }; + + searchInput.onfocus = () => renderDropdown(searchInput.value); + + // Close on outside click + document.addEventListener("click", (e) => { + if (!this.shadowRoot.getElementById("productSelector")?.contains(e.target)) { + dropdown.classList.remove("open"); + } + }); + } + + async respond() { + const response = this.shadowRoot.getElementById("responseInput").value.trim(); + if (!response) { + alert("Escribe una respuesta"); + return; + } + + const addAliasCheck = this.shadowRoot.getElementById("addAliasCheck"); + let addAlias = null; + + if (addAliasCheck.checked && this._selectedProductId) { + addAlias = { + query: this.selected.pending_query, + woo_product_id: this._selectedProductId, + }; + } + + try { + await api.respondTakeover(this.selected.id, { response, add_alias: addAlias }); + alert("Respuesta enviada"); + this.selected = null; + await this.load(); + this.renderForm(); + } catch (e) { + console.error("Error responding:", e); + alert("Error: " + (e.message || e)); + } + } + + async cancel() { + if (!confirm("Cancelar este takeover? El cliente no recibira respuesta automatica.")) { + return; + } + + try { + await api.cancelTakeover(this.selected.id); + alert("Takeover cancelado"); + this.selected = null; + await this.load(); + this.renderForm(); + } catch (e) { + console.error("Error cancelling:", e); + alert("Error: " + (e.message || e)); + } + } +} + +customElements.define("takeovers-crud", TakeoversCrud); diff --git a/public/lib/api.js b/public/lib/api.js index 121b6bc..b50cab4 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -219,4 +219,84 @@ export const api = { body: JSON.stringify({ woo_order_id, amount }), }).then(r => r.json()); }, + + // --- Prompts CRUD --- + async prompts() { + return fetch("/prompts").then(r => r.json()); + }, + + async getPrompt(key) { + return fetch(`/prompts/${encodeURIComponent(key)}`).then(r => r.json()); + }, + + async savePrompt(key, { content, model, created_by }) { + return fetch(`/prompts/${encodeURIComponent(key)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content, model, created_by }), + }).then(r => r.json()); + }, + + async rollbackPrompt(key, version) { + return fetch(`/prompts/${encodeURIComponent(key)}/rollback/${version}`, { + method: "POST" + }).then(r => r.json()); + }, + + async resetPrompt(key) { + return fetch(`/prompts/${encodeURIComponent(key)}/reset`, { + method: "POST" + }).then(r => r.json()); + }, + + async testPrompt(key, { content, test_message, store_config }) { + return fetch(`/prompts/${encodeURIComponent(key)}/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content, test_message, store_config }), + }).then(r => r.json()); + }, + + // --- Human Takeovers --- + async takeovers({ limit = 50 } = {}) { + const u = new URL("/takeovers", location.origin); + u.searchParams.set("limit", String(limit)); + return fetch(u).then(r => r.json()); + }, + + async getTakeover(id) { + return fetch(`/takeovers/${id}`).then(r => r.json()); + }, + + async respondTakeover(id, { response, add_alias }) { + return fetch(`/takeovers/${id}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response, add_alias }), + }).then(r => r.json()); + }, + + async cancelTakeover(id) { + return fetch(`/takeovers/${id}/cancel`, { + method: "POST" + }).then(r => r.json()); + }, + + // --- Settings --- + async getSettings() { + return fetch("/settings").then(r => r.json()); + }, + + async saveSettings(settings) { + const res = await fetch("/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + const data = await res.json(); + if (!res.ok || data.ok === false) { + throw new Error(data.message || data.error || "Error guardando configuración"); + } + return data; + }, }; diff --git a/public/lib/router.js b/public/lib/router.js index 282a965..523b332 100644 --- a/public/lib/router.js +++ b/public/lib/router.js @@ -16,6 +16,9 @@ const ROUTES = [ { pattern: /^\/pedidos$/, view: "orders", params: [] }, { pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] }, { pattern: /^\/test$/, view: "test", params: [] }, + { pattern: /^\/config-prompts$/, view: "prompts", params: [] }, + { pattern: /^\/atencion-humana$/, view: "takeovers", params: [] }, + { pattern: /^\/configuracion$/, view: "settings", params: [] }, ]; // Mapeo de vistas a rutas base (para navegación sin parámetros) @@ -29,6 +32,9 @@ const VIEW_TO_PATH = { quantities: "/cantidades", orders: "/pedidos", test: "/test", + prompts: "/config-prompts", + takeovers: "/atencion-humana", + settings: "/configuracion", }; /** diff --git a/src/app.js b/src/app.js index 5a10fb2..c40682f 100644 --- a/src/app.js +++ b/src/app.js @@ -34,7 +34,8 @@ export function createApp({ tenantId }) { // SPA catch-all - sirve index.html para todas las rutas del frontend const spaRoutes = [ '/chat', '/conversaciones', '/usuarios', '/productos', - '/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test' + '/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test', + '/config-prompts', '/atencion-humana', '/configuracion' ]; app.get(spaRoutes, (req, res) => { res.sendFile(path.join(publicDir, "index.html")); diff --git a/src/modules/0-ui/controllers/prompts.js b/src/modules/0-ui/controllers/prompts.js new file mode 100644 index 0000000..d132fa7 --- /dev/null +++ b/src/modules/0-ui/controllers/prompts.js @@ -0,0 +1,158 @@ +import { + handleListPrompts, + handleGetPrompt, + handleSavePrompt, + handleRollbackPrompt, + handleResetPrompt, + handleGetPromptVersion, + handleTestPrompt, +} from "../handlers/prompts.js"; + +/** + * GET /prompts - Lista todos los prompts del tenant + */ +export const makeListPrompts = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const result = await handleListPrompts({ tenantId }); + res.json(result); + } catch (err) { + console.error("[prompts] List error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * GET /prompts/:key - Obtiene un prompt específico con versiones + */ +export const makeGetPrompt = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const promptKey = req.params.key; + const result = await handleGetPrompt({ tenantId, promptKey }); + res.json(result); + } catch (err) { + console.error("[prompts] Get error:", err); + if (err.message.includes("Invalid prompt_key")) { + return res.status(400).json({ ok: false, error: "invalid_prompt_key" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /prompts/:key - Crea/actualiza un prompt (nueva versión) + */ +export const makeSavePrompt = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const promptKey = req.params.key; + const { content, model, created_by } = req.body || {}; + + if (!content) { + return res.status(400).json({ ok: false, error: "content_required" }); + } + + const result = await handleSavePrompt({ + tenantId, + promptKey, + content, + model, + createdBy: created_by || null, + }); + res.json(result); + } catch (err) { + console.error("[prompts] Save error:", err); + if (err.message.includes("Invalid prompt_key")) { + return res.status(400).json({ ok: false, error: "invalid_prompt_key" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /prompts/:key/rollback/:version - Restaura una versión anterior + */ +export const makeRollbackPrompt = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const { key, version } = req.params; + const { created_by } = req.body || {}; + + const result = await handleRollbackPrompt({ + tenantId, + promptKey: key, + toVersion: version, + createdBy: created_by || null, + }); + res.json(result); + } catch (err) { + console.error("[prompts] Rollback error:", err); + if (err.message.includes("not found")) { + return res.status(404).json({ ok: false, error: "version_not_found" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /prompts/:key/reset - Resetea al default + */ +export const makeResetPrompt = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const promptKey = req.params.key; + + const result = await handleResetPrompt({ tenantId, promptKey }); + res.json(result); + } catch (err) { + console.error("[prompts] Reset error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * GET /prompts/:key/versions/:version - Obtiene contenido de una versión específica + */ +export const makeGetPromptVersion = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const { key, version } = req.params; + + const result = await handleGetPromptVersion({ tenantId, promptKey: key, version }); + res.json(result); + } catch (err) { + console.error("[prompts] GetVersion error:", err); + if (err.message.includes("not found")) { + return res.status(404).json({ ok: false, error: "version_not_found" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /prompts/:key/test - Prueba un prompt con un mensaje + */ +export const makeTestPrompt = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const promptKey = req.params.key; + const { content, test_message, store_config } = req.body || {}; + + if (!test_message) { + return res.status(400).json({ ok: false, error: "test_message_required" }); + } + + const result = await handleTestPrompt({ + tenantId, + promptKey, + content, + testMessage: test_message, + storeConfig: store_config || {}, + }); + res.json(result); + } catch (err) { + console.error("[prompts] Test error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; diff --git a/src/modules/0-ui/controllers/settings.js b/src/modules/0-ui/controllers/settings.js new file mode 100644 index 0000000..fe32222 --- /dev/null +++ b/src/modules/0-ui/controllers/settings.js @@ -0,0 +1,36 @@ +import { handleGetSettings, handleSaveSettings } from "../handlers/settings.js"; + +/** + * GET /settings - Obtiene la configuración del tenant + */ +export const makeGetSettings = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const result = await handleGetSettings({ tenantId }); + res.json(result); + } catch (err) { + console.error("[settings] Get error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /settings - Guarda la configuración del tenant + */ +export const makeSaveSettings = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const settings = req.body || {}; + + const result = await handleSaveSettings({ tenantId, settings }); + res.json(result); + } catch (err) { + console.error("[settings] Save error:", err); + + if (err.message.includes("required") || err.message.includes("Invalid")) { + return res.status(400).json({ ok: false, error: "validation_error", message: err.message }); + } + + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; diff --git a/src/modules/0-ui/controllers/takeovers.js b/src/modules/0-ui/controllers/takeovers.js new file mode 100644 index 0000000..b22dd9d --- /dev/null +++ b/src/modules/0-ui/controllers/takeovers.js @@ -0,0 +1,126 @@ +import { + handleListPendingTakeovers, + handleListAllTakeovers, + handleGetTakeover, + handleRespondToTakeover, + handleCancelTakeover, + handleCheckPendingTakeover, +} from "../handlers/takeovers.js"; + +/** + * GET /takeovers - Lista takeovers pendientes + */ +export const makeListPendingTakeovers = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const limit = parseInt(req.query.limit, 10) || 50; + const result = await handleListPendingTakeovers({ tenantId, limit }); + res.json(result); + } catch (err) { + console.error("[takeovers] List pending error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * GET /takeovers/all - Lista todos los takeovers + */ +export const makeListAllTakeovers = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const status = req.query.status || null; + const limit = parseInt(req.query.limit, 10) || 100; + const result = await handleListAllTakeovers({ tenantId, status, limit }); + res.json(result); + } catch (err) { + console.error("[takeovers] List all error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * GET /takeovers/:id - Obtiene detalles de un takeover + */ +export const makeGetTakeover = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const id = parseInt(req.params.id, 10); + const result = await handleGetTakeover({ tenantId, id }); + res.json(result); + } catch (err) { + console.error("[takeovers] Get error:", err); + if (err.message.includes("not found")) { + return res.status(404).json({ ok: false, error: "takeover_not_found" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /takeovers/:id/respond - Responde a un takeover + */ +export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const id = parseInt(req.params.id, 10); + const { response, responded_by, add_alias } = req.body || {}; + + if (!response) { + return res.status(400).json({ ok: false, error: "response_required" }); + } + + const result = await handleRespondToTakeover({ + tenantId, + id, + response, + respondedBy: responded_by || null, + addAlias: add_alias || null, + }); + res.json(result); + } catch (err) { + console.error("[takeovers] Respond error:", err); + if (err.message.includes("not found") || err.message.includes("already")) { + return res.status(404).json({ ok: false, error: "takeover_not_found_or_processed" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * POST /takeovers/:id/cancel - Cancela un takeover + */ +export const makeCancelTakeover = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const id = parseInt(req.params.id, 10); + const { responded_by } = req.body || {}; + + const result = await handleCancelTakeover({ + tenantId, + id, + respondedBy: responded_by || null, + }); + res.json(result); + } catch (err) { + console.error("[takeovers] Cancel error:", err); + if (err.message.includes("not found") || err.message.includes("already")) { + return res.status(404).json({ ok: false, error: "takeover_not_found_or_processed" }); + } + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; + +/** + * GET /takeovers/check/:chatId - Verifica si hay takeover pendiente para un chat + */ +export const makeCheckPendingTakeover = (tenantIdOrFn) => async (req, res) => { + try { + const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; + const chatId = req.params.chatId; + const result = await handleCheckPendingTakeover({ tenantId, chatId }); + res.json(result); + } catch (err) { + console.error("[takeovers] Check pending error:", err); + res.status(500).json({ ok: false, error: "internal_error", message: err.message }); + } +}; diff --git a/src/modules/0-ui/db/promptsRepo.js b/src/modules/0-ui/db/promptsRepo.js new file mode 100644 index 0000000..8be0f97 --- /dev/null +++ b/src/modules/0-ui/db/promptsRepo.js @@ -0,0 +1,183 @@ +import { pool } from "../../shared/db/pool.js"; + +// ───────────────────────────────────────────────────────────── +// Prompt Templates - CRUD con versionado +// ───────────────────────────────────────────────────────────── + +// Prompt keys válidos +export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"]; + +// Modelos por defecto para cada prompt +export const DEFAULT_MODELS = { + router: "gpt-4o-mini", + greeting: "gpt-4-turbo", + orders: "gpt-4-turbo", + shipping: "gpt-4o-mini", + payment: "gpt-4o-mini", + browse: "gpt-4-turbo", +}; + +/** + * Obtiene el prompt activo para un tenant y key + * @returns {Object|null} { id, prompt_key, content, model, version, is_active, created_at, created_by } + */ +export async function getActivePrompt({ tenantId, promptKey }) { + const sql = ` + SELECT id, prompt_key, content, model, version, is_active, created_at, created_by + FROM prompt_templates + WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true + LIMIT 1 + `; + const { rows } = await pool.query(sql, [tenantId, promptKey]); + return rows[0] || null; +} + +/** + * Lista todos los prompts activos de un tenant + */ +export async function listActivePrompts({ tenantId }) { + const sql = ` + SELECT id, prompt_key, content, model, version, is_active, created_at, created_by + FROM prompt_templates + WHERE tenant_id = $1 AND is_active = true + ORDER BY prompt_key + `; + const { rows } = await pool.query(sql, [tenantId]); + return rows; +} + +/** + * Obtiene todas las versiones de un prompt + */ +export async function getPromptVersions({ tenantId, promptKey, limit = 20 }) { + const sql = ` + SELECT id, prompt_key, content, model, version, is_active, created_at, created_by + FROM prompt_templates + WHERE tenant_id = $1 AND prompt_key = $2 + ORDER BY version DESC + LIMIT $3 + `; + const { rows } = await pool.query(sql, [tenantId, promptKey, limit]); + return rows; +} + +/** + * Obtiene una versión específica de un prompt + */ +export async function getPromptVersion({ tenantId, promptKey, version }) { + const sql = ` + SELECT id, prompt_key, content, model, version, is_active, created_at, created_by + FROM prompt_templates + WHERE tenant_id = $1 AND prompt_key = $2 AND version = $3 + LIMIT 1 + `; + const { rows } = await pool.query(sql, [tenantId, promptKey, version]); + return rows[0] || null; +} + +/** + * Desactiva el prompt activo actual (para crear nueva versión) + */ +export async function deactivatePrompt({ tenantId, promptKey }) { + const sql = ` + UPDATE prompt_templates + SET is_active = false + WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true + `; + await pool.query(sql, [tenantId, promptKey]); +} + +/** + * Crea una nueva versión del prompt (automáticamente desactiva la anterior) + * @returns {Object} El prompt creado con su versión + */ +export async function createPrompt({ tenantId, promptKey, content, model, createdBy = null }) { + // Validar prompt_key + if (!PROMPT_KEYS.includes(promptKey)) { + throw new Error(`Invalid prompt_key: ${promptKey}. Valid keys: ${PROMPT_KEYS.join(", ")}`); + } + + // Desactivar versión anterior + await deactivatePrompt({ tenantId, promptKey }); + + // Insertar nueva versión (el trigger calcula la versión automáticamente) + const sql = ` + INSERT INTO prompt_templates (tenant_id, prompt_key, content, model, is_active, created_by) + VALUES ($1, $2, $3, $4, true, $5) + RETURNING id, prompt_key, content, model, version, is_active, created_at, created_by + `; + const { rows } = await pool.query(sql, [ + tenantId, + promptKey, + content, + model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo", + createdBy, + ]); + return rows[0]; +} + +/** + * Restaura una versión anterior del prompt (crea nueva versión con el contenido antiguo) + */ +export async function rollbackPrompt({ tenantId, promptKey, toVersion, createdBy = null }) { + // Obtener la versión a restaurar + const oldVersion = await getPromptVersion({ tenantId, promptKey, version: toVersion }); + if (!oldVersion) { + throw new Error(`Version ${toVersion} not found for prompt ${promptKey}`); + } + + // Crear nueva versión con el contenido antiguo + return createPrompt({ + tenantId, + promptKey, + content: oldVersion.content, + model: oldVersion.model, + createdBy, + }); +} + +/** + * Resetea un prompt a su default (desactiva todas las versiones custom) + */ +export async function resetPromptToDefault({ tenantId, promptKey }) { + const sql = ` + UPDATE prompt_templates + SET is_active = false + WHERE tenant_id = $1 AND prompt_key = $2 + `; + await pool.query(sql, [tenantId, promptKey]); + return { success: true, message: `Prompt ${promptKey} reset to default` }; +} + +/** + * Elimina todas las versiones de un prompt (usar con cuidado) + */ +export async function deleteAllPromptVersions({ tenantId, promptKey }) { + const sql = ` + DELETE FROM prompt_templates + WHERE tenant_id = $1 AND prompt_key = $2 + RETURNING id + `; + const { rows } = await pool.query(sql, [tenantId, promptKey]); + return { deleted: rows.length }; +} + +/** + * Obtiene estadísticas de prompts de un tenant + */ +export async function getPromptStats({ tenantId }) { + const sql = ` + SELECT + prompt_key, + COUNT(*) as total_versions, + MAX(version) as latest_version, + MAX(CASE WHEN is_active THEN version END) as active_version, + MAX(created_at) as last_updated + FROM prompt_templates + WHERE tenant_id = $1 + GROUP BY prompt_key + ORDER BY prompt_key + `; + const { rows } = await pool.query(sql, [tenantId]); + return rows; +} diff --git a/src/modules/0-ui/db/settingsRepo.js b/src/modules/0-ui/db/settingsRepo.js new file mode 100644 index 0000000..869f914 --- /dev/null +++ b/src/modules/0-ui/db/settingsRepo.js @@ -0,0 +1,174 @@ +import { pool } from "../../shared/db/pool.js"; + +// ───────────────────────────────────────────────────────────── +// Tenant Settings - CRUD +// ───────────────────────────────────────────────────────────── + +/** + * Obtiene la configuración del tenant + */ +export async function getSettings({ tenantId }) { + const sql = ` + SELECT + id, tenant_id, + store_name, bot_name, store_address, store_phone, + delivery_enabled, delivery_days, + delivery_hours_start::text as delivery_hours_start, + delivery_hours_end::text as delivery_hours_end, + delivery_min_order, + pickup_enabled, pickup_days, + pickup_hours_start::text as pickup_hours_start, + pickup_hours_end::text as pickup_hours_end, + created_at, updated_at + FROM tenant_settings + WHERE tenant_id = $1 + LIMIT 1 + `; + const { rows } = await pool.query(sql, [tenantId]); + return rows[0] || null; +} + +/** + * Crea o actualiza la configuración del tenant (upsert) + */ +export async function upsertSettings({ tenantId, settings }) { + const { + store_name, + bot_name, + store_address, + store_phone, + delivery_enabled, + delivery_days, + delivery_hours_start, + delivery_hours_end, + delivery_min_order, + pickup_enabled, + pickup_days, + pickup_hours_start, + pickup_hours_end, + } = settings; + + const sql = ` + INSERT INTO tenant_settings ( + tenant_id, store_name, bot_name, store_address, store_phone, + delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order, + pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (tenant_id) DO UPDATE SET + store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name), + bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name), + store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address), + store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone), + delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled), + delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days), + delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start), + delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end), + delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order), + pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled), + pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days), + pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start), + pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end), + updated_at = NOW() + RETURNING + id, tenant_id, + store_name, bot_name, store_address, store_phone, + delivery_enabled, delivery_days, + delivery_hours_start::text as delivery_hours_start, + delivery_hours_end::text as delivery_hours_end, + delivery_min_order, + pickup_enabled, pickup_days, + pickup_hours_start::text as pickup_hours_start, + pickup_hours_end::text as pickup_hours_end, + created_at, updated_at + `; + + const params = [ + tenantId, + store_name || null, + bot_name || null, + store_address || null, + store_phone || null, + delivery_enabled ?? null, + delivery_days || null, + delivery_hours_start || null, + delivery_hours_end || null, + delivery_min_order ?? null, + pickup_enabled ?? null, + pickup_days || null, + pickup_hours_start || null, + pickup_hours_end || null, + ]; + + console.log("[settingsRepo] upsertSettings params:", params); + + const { rows } = await pool.query(sql, params); + + console.log("[settingsRepo] upsertSettings result:", rows[0]); + + return rows[0]; +} + +/** + * Obtiene la configuración formateada para usar en prompts (storeConfig) + */ +export async function getStoreConfig({ tenantId }) { + const settings = await getSettings({ tenantId }); + + if (!settings) { + // Valores por defecto si no hay configuración + return { + name: "la carnicería", + botName: "Piaf", + hours: "", + address: "", + phone: "", + deliveryHours: "", + pickupHours: "", + }; + } + + // Formatear horarios para mostrar + const formatHours = (enabled, days, start, end) => { + if (!enabled) return "No disponible"; + if (!days || !start || !end) return ""; + + const daysFormatted = days.split(",").map(d => d.trim()).join(", "); + const startFormatted = start?.slice(0, 5) || ""; + const endFormatted = end?.slice(0, 5) || ""; + + return `${daysFormatted} de ${startFormatted} a ${endFormatted}`; + }; + + const deliveryHours = formatHours( + settings.delivery_enabled, + settings.delivery_days, + settings.delivery_hours_start, + settings.delivery_hours_end + ); + + const pickupHours = formatHours( + settings.pickup_enabled, + settings.pickup_days, + settings.pickup_hours_start, + settings.pickup_hours_end + ); + + // Combinar horarios para store_hours + let storeHours = ""; + if (settings.pickup_enabled && settings.pickup_days) { + storeHours = `${settings.pickup_days.split(",").join(", ")} ${settings.pickup_hours_start?.slice(0,5) || ""}-${settings.pickup_hours_end?.slice(0,5) || ""}`; + } + + return { + name: settings.store_name || "la carnicería", + botName: settings.bot_name || "Piaf", + hours: storeHours, + address: settings.store_address || "", + phone: settings.store_phone || "", + deliveryHours, + pickupHours, + deliveryEnabled: settings.delivery_enabled, + pickupEnabled: settings.pickup_enabled, + }; +} diff --git a/src/modules/0-ui/db/takeoverRepo.js b/src/modules/0-ui/db/takeoverRepo.js new file mode 100644 index 0000000..7dbb71d --- /dev/null +++ b/src/modules/0-ui/db/takeoverRepo.js @@ -0,0 +1,182 @@ +import { pool } from "../../shared/db/pool.js"; + +// ───────────────────────────────────────────────────────────── +// Human Takeovers - CRUD +// ───────────────────────────────────────────────────────────── + +/** + * Crea un nuevo takeover (conversación esperando respuesta humana) + */ +export async function createTakeover({ + tenantId, + chatId, + pendingQuery, + reason = "product_not_found", + contextSnapshot = null +}) { + const sql = ` + INSERT INTO human_takeovers (tenant_id, chat_id, pending_query, reason, context_snapshot) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, tenant_id, chat_id, pending_query, reason, status, created_at + `; + const { rows } = await pool.query(sql, [ + tenantId, + chatId, + pendingQuery, + reason, + contextSnapshot ? JSON.stringify(contextSnapshot) : null, + ]); + return rows[0]; +} + +/** + * Lista takeovers pendientes de un tenant + */ +export async function listPendingTakeovers({ tenantId, limit = 50 }) { + const sql = ` + SELECT + id, tenant_id, chat_id, pending_query, reason, + context_snapshot, status, created_at, updated_at + FROM human_takeovers + WHERE tenant_id = $1 AND status = 'pending' + ORDER BY created_at DESC + LIMIT $2 + `; + const { rows } = await pool.query(sql, [tenantId, limit]); + return rows; +} + +/** + * Lista todos los takeovers de un tenant (incluyendo respondidos) + */ +export async function listAllTakeovers({ tenantId, status = null, limit = 100 }) { + let sql, params; + + if (status) { + sql = ` + SELECT + id, tenant_id, chat_id, pending_query, reason, + context_snapshot, status, human_response, responded_at, responded_by, + created_at, updated_at + FROM human_takeovers + WHERE tenant_id = $1 AND status = $2 + ORDER BY created_at DESC + LIMIT $3 + `; + params = [tenantId, status, limit]; + } else { + sql = ` + SELECT + id, tenant_id, chat_id, pending_query, reason, + context_snapshot, status, human_response, responded_at, responded_by, + created_at, updated_at + FROM human_takeovers + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + params = [tenantId, limit]; + } + + const { rows } = await pool.query(sql, params); + return rows; +} + +/** + * Obtiene un takeover por ID + */ +export async function getTakeoverById({ tenantId, id }) { + const sql = ` + SELECT + id, tenant_id, chat_id, pending_query, reason, + context_snapshot, status, human_response, responded_at, responded_by, + created_at, updated_at + FROM human_takeovers + WHERE tenant_id = $1 AND id = $2 + LIMIT 1 + `; + const { rows } = await pool.query(sql, [tenantId, id]); + return rows[0] || null; +} + +/** + * Obtiene takeover pendiente de un chat específico + */ +export async function getPendingTakeoverByChat({ tenantId, chatId }) { + const sql = ` + SELECT + id, tenant_id, chat_id, pending_query, reason, + context_snapshot, status, created_at, updated_at + FROM human_takeovers + WHERE tenant_id = $1 AND chat_id = $2 AND status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + `; + const { rows } = await pool.query(sql, [tenantId, chatId]); + return rows[0] || null; +} + +/** + * Responde a un takeover + */ +export async function respondToTakeover({ tenantId, id, humanResponse, respondedBy = null }) { + const sql = ` + UPDATE human_takeovers + SET + status = 'responded', + human_response = $3, + responded_at = NOW(), + responded_by = $4 + WHERE tenant_id = $1 AND id = $2 AND status = 'pending' + RETURNING id, chat_id, pending_query, human_response, responded_at, responded_by, status + `; + const { rows } = await pool.query(sql, [tenantId, id, humanResponse, respondedBy]); + return rows[0] || null; +} + +/** + * Cancela un takeover + */ +export async function cancelTakeover({ tenantId, id, respondedBy = null }) { + const sql = ` + UPDATE human_takeovers + SET + status = 'cancelled', + responded_at = NOW(), + responded_by = $3 + WHERE tenant_id = $1 AND id = $2 AND status = 'pending' + RETURNING id, chat_id, status + `; + const { rows } = await pool.query(sql, [tenantId, id, respondedBy]); + return rows[0] || null; +} + +/** + * Cuenta takeovers pendientes por tenant + */ +export async function countPendingTakeovers({ tenantId }) { + const sql = ` + SELECT COUNT(*) as count + FROM human_takeovers + WHERE tenant_id = $1 AND status = 'pending' + `; + const { rows } = await pool.query(sql, [tenantId]); + return parseInt(rows[0]?.count || 0, 10); +} + +/** + * Obtiene estadísticas de takeovers + */ +export async function getTakeoverStats({ tenantId }) { + const sql = ` + SELECT + status, + COUNT(*) as count, + AVG(EXTRACT(EPOCH FROM (COALESCE(responded_at, NOW()) - created_at))) as avg_response_time_seconds + FROM human_takeovers + WHERE tenant_id = $1 + GROUP BY status + `; + const { rows } = await pool.query(sql, [tenantId]); + return rows; +} diff --git a/src/modules/0-ui/handlers/prompts.js b/src/modules/0-ui/handlers/prompts.js new file mode 100644 index 0000000..659e087 --- /dev/null +++ b/src/modules/0-ui/handlers/prompts.js @@ -0,0 +1,258 @@ +import { + getActivePrompt, + listActivePrompts, + getPromptVersions, + getPromptVersion, + createPrompt, + rollbackPrompt, + resetPromptToDefault, + getPromptStats, + PROMPT_KEYS, + DEFAULT_MODELS, +} from "../db/promptsRepo.js"; +import { loadDefaultPrompt, AVAILABLE_VARIABLES, invalidatePromptCache } from "../../3-turn-engine/nlu/promptLoader.js"; + +/** + * Lista todos los prompts del tenant (activos + defaults para los que no tienen custom) + */ +export async function handleListPrompts({ tenantId }) { + const activePrompts = await listActivePrompts({ tenantId }); + const stats = await getPromptStats({ tenantId }); + + // Construir lista completa con defaults para los que no tienen custom + const promptsMap = new Map(activePrompts.map(p => [p.prompt_key, p])); + + const items = PROMPT_KEYS.map(key => { + const custom = promptsMap.get(key); + const stat = stats.find(s => s.prompt_key === key); + + if (custom) { + return { + prompt_key: key, + content: custom.content, + model: custom.model, + version: custom.version, + is_default: false, + total_versions: stat?.total_versions || 1, + last_updated: custom.created_at, + created_by: custom.created_by, + }; + } else { + // Cargar default + let defaultContent = ""; + try { + defaultContent = loadDefaultPrompt(key); + } catch (e) { + defaultContent = `[Error loading default: ${e.message}]`; + } + + return { + prompt_key: key, + content: defaultContent, + model: DEFAULT_MODELS[key] || "gpt-4-turbo", + version: null, + is_default: true, + total_versions: stat?.total_versions || 0, + last_updated: null, + created_by: null, + }; + } + }); + + return { + items, + available_variables: AVAILABLE_VARIABLES, + available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"], + }; +} + +/** + * Obtiene un prompt específico con su historial de versiones + */ +export async function handleGetPrompt({ tenantId, promptKey }) { + if (!PROMPT_KEYS.includes(promptKey)) { + throw new Error(`Invalid prompt_key: ${promptKey}`); + } + + const current = await getActivePrompt({ tenantId, promptKey }); + const versions = await getPromptVersions({ tenantId, promptKey, limit: 20 }); + + let defaultContent = ""; + try { + defaultContent = loadDefaultPrompt(promptKey); + } catch (e) { + defaultContent = `[Error: ${e.message}]`; + } + + return { + prompt_key: promptKey, + current: current || { + content: defaultContent, + model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo", + version: null, + is_default: true, + }, + default_content: defaultContent, + default_model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo", + versions: versions.map(v => ({ + version: v.version, + is_active: v.is_active, + created_at: v.created_at, + created_by: v.created_by, + content_preview: v.content.slice(0, 100) + (v.content.length > 100 ? "..." : ""), + })), + available_variables: AVAILABLE_VARIABLES, + available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"], + }; +} + +/** + * Crea o actualiza un prompt (crea nueva versión) + */ +export async function handleSavePrompt({ tenantId, promptKey, content, model, createdBy }) { + if (!PROMPT_KEYS.includes(promptKey)) { + throw new Error(`Invalid prompt_key: ${promptKey}`); + } + + if (!content || content.trim().length === 0) { + throw new Error("Content is required"); + } + + const result = await createPrompt({ + tenantId, + promptKey, + content: content.trim(), + model: model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo", + createdBy, + }); + + // Invalidar cache + invalidatePromptCache(tenantId, promptKey); + + return { + ok: true, + item: result, + message: `Prompt ${promptKey} saved as version ${result.version}`, + }; +} + +/** + * Restaura una versión anterior del prompt + */ +export async function handleRollbackPrompt({ tenantId, promptKey, toVersion, createdBy }) { + if (!PROMPT_KEYS.includes(promptKey)) { + throw new Error(`Invalid prompt_key: ${promptKey}`); + } + + const result = await rollbackPrompt({ + tenantId, + promptKey, + toVersion: parseInt(toVersion, 10), + createdBy, + }); + + // Invalidar cache + invalidatePromptCache(tenantId, promptKey); + + return { + ok: true, + item: result, + message: `Prompt ${promptKey} rolled back to version ${toVersion}, new version is ${result.version}`, + }; +} + +/** + * Resetea un prompt al default (desactiva todas las versiones custom) + */ +export async function handleResetPrompt({ tenantId, promptKey }) { + if (!PROMPT_KEYS.includes(promptKey)) { + throw new Error(`Invalid prompt_key: ${promptKey}`); + } + + await resetPromptToDefault({ tenantId, promptKey }); + + // Invalidar cache + invalidatePromptCache(tenantId, promptKey); + + return { + ok: true, + message: `Prompt ${promptKey} reset to default`, + }; +} + +/** + * Obtiene el contenido de una versión específica + */ +export async function handleGetPromptVersion({ tenantId, promptKey, version }) { + if (!PROMPT_KEYS.includes(promptKey)) { + throw new Error(`Invalid prompt_key: ${promptKey}`); + } + + const versionData = await getPromptVersion({ + tenantId, + promptKey, + version: parseInt(version, 10) + }); + + if (!versionData) { + throw new Error(`Version ${version} not found for prompt ${promptKey}`); + } + + return { item: versionData }; +} + +/** + * Prueba un prompt con un mensaje de ejemplo + */ +export async function handleTestPrompt({ tenantId, promptKey, content, testMessage, storeConfig = {} }) { + // Importar dinámicamente para evitar dependencias circulares + const { loadPrompt } = await import("../../3-turn-engine/nlu/promptLoader.js"); + + // Si se proporciona content, usarlo directamente + // Si no, cargar el prompt actual + let promptContent = content; + let model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo"; + + if (!promptContent) { + const loaded = await loadPrompt({ tenantId, promptKey, variables: storeConfig }); + promptContent = loaded.content; + model = loaded.model; + } else { + // Aplicar variables al content proporcionado + for (const [key, value] of Object.entries(storeConfig)) { + promptContent = promptContent.replace(new RegExp(`{{${key}}}`, "g"), value || ""); + } + promptContent = promptContent.replace(/\{\{[^}]+\}\}/g, ""); + } + + // Importar OpenAI + const OpenAI = (await import("openai")).default; + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY not configured"); + } + + const openai = new OpenAI({ apiKey }); + + // Hacer la llamada de prueba + const startTime = Date.now(); + const response = await openai.chat.completions.create({ + model, + temperature: 0.2, + max_tokens: 500, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: promptContent }, + { role: "user", content: testMessage }, + ], + }); + const endTime = Date.now(); + + return { + ok: true, + response: response?.choices?.[0]?.message?.content || "", + model, + usage: response?.usage, + latency_ms: endTime - startTime, + }; +} diff --git a/src/modules/0-ui/handlers/settings.js b/src/modules/0-ui/handlers/settings.js new file mode 100644 index 0000000..96d9b8f --- /dev/null +++ b/src/modules/0-ui/handlers/settings.js @@ -0,0 +1,114 @@ +import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js"; + +// Días de la semana para validación +const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]; + +/** + * Obtiene la configuración actual del tenant + */ +export async function handleGetSettings({ tenantId }) { + const settings = await getSettings({ tenantId }); + + // Si no hay configuración, devolver defaults + if (!settings) { + return { + store_name: "Mi Negocio", + bot_name: "Piaf", + store_address: "", + store_phone: "", + delivery_enabled: true, + delivery_days: "lun,mar,mie,jue,vie,sab", + delivery_hours_start: "09:00", + delivery_hours_end: "18:00", + delivery_min_order: 0, + pickup_enabled: true, + pickup_days: "lun,mar,mie,jue,vie,sab", + pickup_hours_start: "08:00", + pickup_hours_end: "20:00", + is_default: true, + }; + } + + return { + ...settings, + // Formatear horarios TIME a HH:MM + delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00", + delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00", + pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00", + pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00", + is_default: false, + }; +} + +/** + * Guarda la configuración del tenant + */ +export async function handleSaveSettings({ tenantId, settings }) { + console.log("[settings] handleSaveSettings", { tenantId, settings }); + + // Validaciones básicas + if (!settings.store_name?.trim()) { + throw new Error("store_name is required"); + } + if (!settings.bot_name?.trim()) { + throw new Error("bot_name is required"); + } + + // Validar días + if (settings.delivery_days) { + const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase()); + for (const day of days) { + if (!VALID_DAYS.includes(day)) { + throw new Error(`Invalid delivery day: ${day}`); + } + } + settings.delivery_days = days.join(","); + } + + if (settings.pickup_days) { + const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase()); + for (const day of days) { + if (!VALID_DAYS.includes(day)) { + throw new Error(`Invalid pickup day: ${day}`); + } + } + settings.pickup_days = days.join(","); + } + + // Validar horarios + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + + if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) { + throw new Error("Invalid delivery_hours_start format (use HH:MM)"); + } + if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) { + throw new Error("Invalid delivery_hours_end format (use HH:MM)"); + } + if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) { + throw new Error("Invalid pickup_hours_start format (use HH:MM)"); + } + if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) { + throw new Error("Invalid pickup_hours_end format (use HH:MM)"); + } + + const result = await upsertSettings({ tenantId, settings }); + + return { + ok: true, + settings: { + ...result, + delivery_hours_start: result.delivery_hours_start?.slice(0, 5), + delivery_hours_end: result.delivery_hours_end?.slice(0, 5), + pickup_hours_start: result.pickup_hours_start?.slice(0, 5), + pickup_hours_end: result.pickup_hours_end?.slice(0, 5), + }, + message: "Configuración guardada correctamente", + }; +} + +/** + * Obtiene el storeConfig formateado para prompts + */ +export async function handleGetStoreConfig({ tenantId }) { + return await getStoreConfig({ tenantId }); +} diff --git a/src/modules/0-ui/handlers/takeovers.js b/src/modules/0-ui/handlers/takeovers.js new file mode 100644 index 0000000..ada9ed8 --- /dev/null +++ b/src/modules/0-ui/handlers/takeovers.js @@ -0,0 +1,246 @@ +import { + createTakeover, + listPendingTakeovers, + listAllTakeovers, + getTakeoverById, + getPendingTakeoverByChat, + respondToTakeover, + cancelTakeover, + countPendingTakeovers, + getTakeoverStats, +} from "../db/takeoverRepo.js"; +import { insertAlias, upsertAliasMapping } from "../db/repo.js"; +import { getRecentMessagesForLLM } from "../../2-identity/db/repo.js"; + +/** + * Lista takeovers pendientes de respuesta + */ +export async function handleListPendingTakeovers({ tenantId, limit = 50 }) { + const items = await listPendingTakeovers({ tenantId, limit }); + const count = await countPendingTakeovers({ tenantId }); + + // Enriquecer con contexto resumido + const enrichedItems = await Promise.all(items.map(async (item) => { + // Obtener últimos mensajes de la conversación + let recentMessages = []; + try { + recentMessages = await getRecentMessagesForLLM({ + tenantId, + chat_id: item.chat_id, + limit: 5 + }); + } catch (e) { + // Ignorar errores al cargar mensajes + } + + return { + ...item, + context_summary: item.context_snapshot + ? summarizeContext(item.context_snapshot) + : null, + recent_messages: recentMessages.map(m => ({ + role: m.role, + content: m.content?.slice(0, 200), + created_at: m.created_at, + })), + }; + })); + + return { items: enrichedItems, pending_count: count }; +} + +/** + * Lista todos los takeovers (con filtro opcional por status) + */ +export async function handleListAllTakeovers({ tenantId, status = null, limit = 100 }) { + const items = await listAllTakeovers({ tenantId, status, limit }); + const stats = await getTakeoverStats({ tenantId }); + + return { items, stats }; +} + +/** + * Obtiene detalles de un takeover específico + */ +export async function handleGetTakeover({ tenantId, id }) { + const takeover = await getTakeoverById({ tenantId, id }); + + if (!takeover) { + throw new Error("Takeover not found"); + } + + // Obtener historial de la conversación + let conversationHistory = []; + try { + conversationHistory = await getRecentMessagesForLLM({ + tenantId, + chat_id: takeover.chat_id, + limit: 20 + }); + } catch (e) { + // Ignorar errores + } + + return { + ...takeover, + context_summary: takeover.context_snapshot + ? summarizeContext(takeover.context_snapshot) + : null, + conversation_history: conversationHistory, + }; +} + +/** + * Responde a un takeover (el humano envía respuesta como el bot) + */ +export async function handleRespondToTakeover({ + tenantId, + id, + response, + respondedBy = null, + addAlias = null, // { query: string, woo_product_id: number } +}) { + // Responder al takeover + const result = await respondToTakeover({ + tenantId, + id, + humanResponse: response, + respondedBy, + }); + + if (!result) { + throw new Error("Takeover not found or already responded"); + } + + // Si se pidió agregar alias, hacerlo + if (addAlias && addAlias.query && addAlias.woo_product_id) { + try { + await insertAlias({ + tenantId, + alias: addAlias.query.toLowerCase().trim(), + woo_product_id: addAlias.woo_product_id, + boost: 0.5, + metadata: { created_from_takeover: id }, + }); + } catch (e) { + // Si el alias ya existe, intentar agregar mapping + if (e.code === "23505") { + await upsertAliasMapping({ + tenantId, + alias: addAlias.query.toLowerCase().trim(), + woo_product_id: addAlias.woo_product_id, + score: 0.8, + }); + } + } + } + + return { + ok: true, + takeover: result, + message: "Response sent successfully", + }; +} + +/** + * Cancela un takeover + */ +export async function handleCancelTakeover({ tenantId, id, respondedBy = null }) { + const result = await cancelTakeover({ tenantId, id, respondedBy }); + + if (!result) { + throw new Error("Takeover not found or already processed"); + } + + return { + ok: true, + takeover: result, + message: "Takeover cancelled", + }; +} + +/** + * Verifica si hay un takeover pendiente para un chat + */ +export async function handleCheckPendingTakeover({ tenantId, chatId }) { + const pending = await getPendingTakeoverByChat({ tenantId, chatId }); + return { + has_pending: !!pending, + pending: pending || null, + }; +} + +/** + * Crea un nuevo takeover (llamado desde el pipeline cuando no se encuentra un producto) + */ +export async function handleCreateTakeover({ + tenantId, + chatId, + pendingQuery, + reason = "product_not_found", + contextSnapshot = null, +}) { + // Verificar si ya hay un takeover pendiente para este chat + const existing = await getPendingTakeoverByChat({ tenantId, chatId }); + if (existing) { + // Actualizar el existente con la nueva query + // Por ahora, retornamos el existente + return { + ok: true, + takeover: existing, + message: "Takeover already pending for this chat", + already_existed: true, + }; + } + + const takeover = await createTakeover({ + tenantId, + chatId, + pendingQuery, + reason, + contextSnapshot, + }); + + return { + ok: true, + takeover, + message: "Takeover created", + already_existed: false, + }; +} + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +function summarizeContext(contextSnapshot) { + if (!contextSnapshot) return null; + + const ctx = typeof contextSnapshot === "string" + ? JSON.parse(contextSnapshot) + : contextSnapshot; + + const summary = []; + + // Cart items + if (ctx.order?.cart?.length > 0) { + const cartItems = ctx.order.cart.map(i => `${i.qty}${i.unit} ${i.name}`).join(", "); + summary.push(`Carrito: ${cartItems}`); + } + + // Pending items + if (ctx.order?.pending?.length > 0) { + const pendingItems = ctx.order.pending.map(i => i.query).join(", "); + summary.push(`Pendiente: ${pendingItems}`); + } + + // Shipping/Payment + if (ctx.order?.is_delivery !== null) { + summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro"); + } + if (ctx.order?.payment_type) { + summary.push(`Pago: ${ctx.order.payment_type}`); + } + + return summary.join(" | ") || "Sin contexto"; +} diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index 0f8e5dd..eeeb12a 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -10,6 +10,9 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js"; import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js"; import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js"; +import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, makeResetPrompt, makeGetPromptVersion, makeTestPrompt } from "../../0-ui/controllers/prompts.js"; +import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js"; +import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js"; import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js"; @@ -77,6 +80,27 @@ export function createSimulatorRouter({ tenantId }) { router.get("/quantities/:wooProductId", makeGetProductQtyRules(getTenantId)); router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId)); + // --- Prompts routes --- + router.get("/prompts", makeListPrompts(getTenantId)); + router.get("/prompts/:key", makeGetPrompt(getTenantId)); + router.post("/prompts/:key", makeSavePrompt(getTenantId)); + router.post("/prompts/:key/rollback/:version", makeRollbackPrompt(getTenantId)); + router.post("/prompts/:key/reset", makeResetPrompt(getTenantId)); + router.get("/prompts/:key/versions/:version", makeGetPromptVersion(getTenantId)); + router.post("/prompts/:key/test", makeTestPrompt(getTenantId)); + + // --- Human Takeovers routes --- + router.get("/takeovers", makeListPendingTakeovers(getTenantId)); + router.get("/takeovers/all", makeListAllTakeovers(getTenantId)); + router.get("/takeovers/check/:chatId", makeCheckPendingTakeover(getTenantId)); + router.get("/takeovers/:id", makeGetTakeover(getTenantId)); + router.post("/takeovers/:id/respond", makeRespondToTakeover(getTenantId)); + router.post("/takeovers/:id/cancel", makeCancelTakeover(getTenantId)); + + // --- Settings routes --- + router.get("/settings", makeGetSettings(getTenantId)); + router.post("/settings", makeSaveSettings(getTenantId)); + router.get("/users", makeListUsers(getTenantId)); router.delete("/users/:chat_id", makeDeleteUser(getTenantId)); diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index 5f011cc..ea17686 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -232,22 +232,36 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; for (const act of actions) { try { if (act.type === "create_order") { + const basketToUse = reducedContext?.order_basket || plan?.basket_resolved || { items: [] }; + // Construir address con phone de fallback desde wa_chat_id + const baseAddress = reducedContext?.delivery_address || reducedContext?.address || {}; + const phoneFromWa = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || ""; + const addressWithPhone = { + ...baseAddress, + phone: baseAddress.phone || phoneFromWa, + }; const order = await createOrder({ tenantId, wooCustomerId: externalCustomerId, - basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] }, - address: reducedContext?.delivery_address || reducedContext?.address || null, + basket: basketToUse, + address: addressWithPhone, run_id: null, }); actionPatch.woo_order_id = order?.id || null; actionPatch.order_total = calcOrderTotal(order); newTools.push({ type: "create_order", ok: true, order_id: order?.id || null }); } else if (act.type === "update_order") { + const baseAddrUpd = reducedContext?.delivery_address || reducedContext?.address || {}; + const phoneFromWaUpd = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || ""; + const addressWithPhoneUpd = { + ...baseAddrUpd, + phone: baseAddrUpd.phone || phoneFromWaUpd, + }; const order = await updateOrder({ tenantId, wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null, basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] }, - address: reducedContext?.delivery_address || reducedContext?.address || null, + address: addressWithPhoneUpd, run_id: null, }); actionPatch.woo_order_id = order?.id || null; diff --git a/src/modules/3-turn-engine/fsm.js b/src/modules/3-turn-engine/fsm.js index e7668d6..1e8abca 100644 --- a/src/modules/3-turn-engine/fsm.js +++ b/src/modules/3-turn-engine/fsm.js @@ -11,6 +11,7 @@ export const ConversationState = Object.freeze({ SHIPPING: "SHIPPING", PAYMENT: "PAYMENT", WAITING_WEBHOOKS: "WAITING_WEBHOOKS", + AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano }); export const ALL_STATES = Object.freeze(Object.values(ConversationState)); @@ -33,23 +34,46 @@ export const INTENTS_BY_STATE = Object.freeze({ [ConversationState.WAITING_WEBHOOKS]: [ "add_to_cart", "view_cart", "other" ], + [ConversationState.AWAITING_HUMAN]: [ + "other" // En este estado, el bot no procesa - espera respuesta humana + ], }); /** * Verifica si el usuario quiere agregar productos (debe volver a CART). */ -export function shouldReturnToCart(state, nlu) { +export function shouldReturnToCart(state, nlu, text = "") { if (state === ConversationState.CART || state === ConversationState.IDLE) { return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente) } + + // En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos + const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT; + const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || ""); + if (isCheckoutState && isJustNumber) { + return false; // No redirigir, es una selección de opción + } + const intent = nlu?.intent; // Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART + // Pero solo si hay una query de producto real (no vacía) if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) { - return true; + // Verificar que hay un producto real mencionado + const hasRealProduct = nlu?.entities?.product_query && + String(nlu.entities.product_query).trim().length > 2; + const hasRealItems = Array.isArray(nlu?.entities?.items) && + nlu.entities.items.some(i => i?.product_query?.trim().length > 2); + if (hasRealProduct || hasRealItems) { + return true; + } + // Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada) + return false; } - // Si hay menciones de producto en entities - if (nlu?.entities?.product_query) return true; - if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) return true; + + // Si hay menciones de producto en entities (con contenido real) + if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true; + if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true; + return false; } @@ -167,26 +191,36 @@ const ALLOWED = Object.freeze({ [ConversationState.IDLE]: [ ConversationState.IDLE, ConversationState.CART, + ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano ], [ConversationState.CART]: [ ConversationState.CART, ConversationState.SHIPPING, ConversationState.IDLE, // Si vacía el carrito + ConversationState.AWAITING_HUMAN, // Producto no encontrado ], [ConversationState.SHIPPING]: [ ConversationState.SHIPPING, ConversationState.PAYMENT, ConversationState.CART, // Volver a agregar productos + ConversationState.AWAITING_HUMAN, ], [ConversationState.PAYMENT]: [ ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS, ConversationState.CART, // Volver a agregar productos + ConversationState.AWAITING_HUMAN, ], [ConversationState.WAITING_WEBHOOKS]: [ ConversationState.WAITING_WEBHOOKS, ConversationState.IDLE, // Pago completado ConversationState.CART, // Agregar más productos + ConversationState.AWAITING_HUMAN, + ], + [ConversationState.AWAITING_HUMAN]: [ + ConversationState.AWAITING_HUMAN, // Sigue esperando + ConversationState.CART, // Humano respondió, volver a procesar + ConversationState.IDLE, // Humano canceló ], }); diff --git a/src/modules/3-turn-engine/nlu/defaults/browse.txt b/src/modules/3-turn-engine/nlu/defaults/browse.txt new file mode 100644 index 0000000..ee6bccc --- /dev/null +++ b/src/modules/3-turn-engine/nlu/defaults/browse.txt @@ -0,0 +1,73 @@ +Sos {{bot_name}}, asistente de {{store_name}}. Procesá consultas sobre el catálogo. + +TIPOS DE CONSULTAS: + +1. price_query - Consulta de precios + Señales: "cuánto sale", "precio de", "cuánto cuesta", "a cuánto está" + Extraer: product_query (el producto que pregunta) + +2. browse - Consulta de disponibilidad + Señales: "tenés", "hay", "vendés", "tienen" + Extraer: product_query + +3. recommend - Pedido de recomendación/planificación + Señales: "qué me recomendás", "qué llevo", "para X personas", "para un asado" + Extraer: + - people_count: número de personas si lo menciona + - event_type: tipo de evento (asado, cumple, reunión) + - product_query: producto específico si lo menciona + +EJEMPLOS: + +Input: "cuánto sale el vacío?" +Output: +{ + "intent": "price_query", + "product_query": "vacío", + "people_count": null, + "event_type": null +} + +Input: "tenés chimichurri?" +Output: +{ + "intent": "browse", + "product_query": "chimichurri", + "people_count": null, + "event_type": null +} + +Input: "qué me recomendás para 8 personas?" +Output: +{ + "intent": "recommend", + "product_query": null, + "people_count": 8, + "event_type": "asado" +} + +Input: "para un asado de 6, qué llevo?" +Output: +{ + "intent": "recommend", + "product_query": null, + "people_count": 6, + "event_type": "asado" +} + +Input: "qué vino va bien con carne?" +Output: +{ + "intent": "recommend", + "product_query": "vino", + "people_count": null, + "event_type": null +} + +FORMATO JSON: +{ + "intent": "price_query|browse|recommend", + "product_query": "texto" | null, + "people_count": number | null, + "event_type": "asado|cumple|reunion" | null +} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/greeting.txt b/src/modules/3-turn-engine/nlu/defaults/greeting.txt new file mode 100644 index 0000000..070e6ae --- /dev/null +++ b/src/modules/3-turn-engine/nlu/defaults/greeting.txt @@ -0,0 +1,23 @@ +Sos {{bot_name}}, el asistente virtual de {{store_name}}. + +PERSONALIDAD: +- Carnicero profesional argentino con años de experiencia +- Usás voseo natural (vos, querés, tenés, decime) +- Amable y cálido pero eficiente, no muy formal +- Conocedor de cortes de carne y tradiciones del asado argentino +- Podés hacer algún comentario simpático sobre el asado si viene al caso +- Respuestas concisas, no te extendés demasiado + +CONTEXTO DEL NEGOCIO: +- Horario: {{store_hours}} +- Dirección: {{store_address}} + +INSTRUCCIONES: +El cliente te saluda. Respondé de forma cálida y preguntá en qué podés ayudar. +Si hay alguna promo del día o corte destacado, mencionalo brevemente. + +FORMATO DE RESPUESTA (JSON): +{ + "intent": "greeting", + "reply": "tu respuesta al cliente" +} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/orders.txt b/src/modules/3-turn-engine/nlu/defaults/orders.txt new file mode 100644 index 0000000..96007f8 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/defaults/orders.txt @@ -0,0 +1,98 @@ +Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje del usuario. + +REGLAS CRÍTICAS (seguir estrictamente): + +1. SIEMPRE USAR ARRAY "items" + Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento. + Cada item tiene: product_query, quantity, unit + +2. COPIAR TEXTO EXACTO + El campo "product_query" debe ser el texto EXACTO que usó el cliente. + - Si dice "asado de tira" → product_query: "asado de tira" + - Si dice "vacío" → product_query: "vacío" + - NUNCA modifiques, combines ni inventes nombres + +3. EXTRAER CANTIDADES + - "2kg de X" → quantity: 2, unit: "kg" + - "3 provoletas" → quantity: 3, unit: "unidad" + - "medio kilo" → quantity: 0.5, unit: "kg" + - Sin cantidad → quantity: null + +4. UNIDADES + - kg: kilos, kilo, kilogramo + - g: gramos, gr + - unidad: unidades, u (para productos que no se pesan) + +5. INTENTS + - add_to_cart: agregar productos (quiero, dame, anotame, poneme) + - remove_from_cart: quitar productos (sacame, quitame) + - view_cart: ver carrito (qué tengo, qué anoté, mi pedido) + - confirm_order: cerrar pedido (listo, eso es todo, cerrar) + +EJEMPLOS: + +Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas" +Output: +{ + "intent": "add_to_cart", + "confidence": 0.95, + "items": [ + {"product_query": "vacío", "quantity": 2, "unit": "kg"}, + {"product_query": "asado de tira", "quantity": 3, "unit": "kg"}, + {"product_query": "chorizos mixtos", "quantity": 1, "unit": "kg"}, + {"product_query": "provoletas", "quantity": 2, "unit": "unidad"} + ] +} + +Input: "dame 1kg de vacío" +Output: +{ + "intent": "add_to_cart", + "confidence": 0.95, + "items": [ + {"product_query": "vacío", "quantity": 1, "unit": "kg"} + ] +} + +Input: "quiero asado" +Output: +{ + "intent": "add_to_cart", + "confidence": 0.9, + "items": [ + {"product_query": "asado", "quantity": null, "unit": null} + ] +} + +Input: "sacame el chorizo" +Output: +{ + "intent": "remove_from_cart", + "confidence": 0.9, + "items": [ + {"product_query": "chorizo", "quantity": null, "unit": null} + ] +} + +Input: "qué tengo anotado?" +Output: +{ + "intent": "view_cart", + "confidence": 0.95, + "items": [] +} + +Input: "listo, eso sería todo" +Output: +{ + "intent": "confirm_order", + "confidence": 0.95, + "items": [] +} + +FORMATO JSON ESTRICTO: +{ + "intent": "add_to_cart|remove_from_cart|view_cart|confirm_order", + "confidence": 0.0-1.0, + "items": [{product_query, quantity, unit}, ...] +} diff --git a/src/modules/3-turn-engine/nlu/defaults/payment.txt b/src/modules/3-turn-engine/nlu/defaults/payment.txt new file mode 100644 index 0000000..87a82cd --- /dev/null +++ b/src/modules/3-turn-engine/nlu/defaults/payment.txt @@ -0,0 +1,60 @@ +Extraé información de pago del mensaje del usuario. + +ENTIDADES A EXTRAER: + +1. payment_method + - "cash": pago en efectivo + Señales: efectivo, cash, plata, en mano + - "link": pago electrónico (tarjeta, transferencia, link de pago) + Señales: tarjeta, link, transferencia, QR, mercadopago, MP + - null: no se puede determinar + +EJEMPLOS: + +Input: "efectivo" +Output: +{ + "intent": "select_payment", + "payment_method": "cash" +} + +Input: "con tarjeta" +Output: +{ + "intent": "select_payment", + "payment_method": "link" +} + +Input: "link de pago" +Output: +{ + "intent": "select_payment", + "payment_method": "link" +} + +Input: "pago cuando llega" +Output: +{ + "intent": "select_payment", + "payment_method": "cash" +} + +Input: "transferencia" +Output: +{ + "intent": "select_payment", + "payment_method": "link" +} + +Input: "1" (si el contexto indica que 1=efectivo) +Output: +{ + "intent": "select_payment", + "payment_method": "cash" +} + +FORMATO JSON: +{ + "intent": "select_payment", + "payment_method": "cash" | "link" | null +} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/router.txt b/src/modules/3-turn-engine/nlu/defaults/router.txt new file mode 100644 index 0000000..afd5857 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/defaults/router.txt @@ -0,0 +1,33 @@ +Clasificá el dominio del mensaje del usuario. Respondé SOLO JSON válido. + +{"domain":"greeting|orders|shipping|payment|browse|other"} + +REGLAS DE CLASIFICACIÓN: + +1. greeting - Saludos sin mención de productos + - "hola", "buen día", "buenas tardes", "qué tal", "hey" + - NO si menciona productos junto al saludo + +2. orders - Todo relacionado con pedidos y productos + - Agregar productos: "quiero", "dame", "anotame", "poneme", cantidad + producto + - Quitar productos: "sacame", "quitame", "no quiero" + - Ver carrito: "qué tengo", "qué anoté", "mi pedido" + - Confirmar: "listo", "eso es todo", "cerrar pedido" + +3. shipping - Envío y entrega + - Método: "delivery", "envío", "retiro", "buscar", "sucursal" + - Dirección: textos con calle, número, barrio + +4. payment - Métodos de pago + - "efectivo", "tarjeta", "transferencia", "link", "mercadopago" + +5. browse - Consultas de catálogo + - Precios: "cuánto sale", "precio de" + - Disponibilidad: "tenés", "hay", "vendés" + - Recomendaciones: "qué me recomendás", "para X personas" + +6. other - Cualquier otra cosa + +Estado actual: {{state}} + +Mensaje a clasificar: [se provee en el input] \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/defaults/shipping.txt b/src/modules/3-turn-engine/nlu/defaults/shipping.txt new file mode 100644 index 0000000..bd54810 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/defaults/shipping.txt @@ -0,0 +1,64 @@ +Extraé información de envío del mensaje del usuario. + +ENTIDADES A EXTRAER: + +1. shipping_method + - "delivery": el cliente quiere que le lleven el pedido + Señales: delivery, envío, enviar, que me lo traigan, llevar + - "pickup": el cliente pasa a buscar + Señales: retiro, retirar, buscar, paso, sucursal + - null: no se puede determinar + +2. address + - Texto de la dirección de entrega + - Solo extraer si hay datos concretos (calle, número, barrio, etc.) + - null: si no hay dirección + +EJEMPLOS: + +Input: "delivery" +Output: +{ + "intent": "select_shipping", + "shipping_method": "delivery", + "address": null +} + +Input: "paso a buscar" +Output: +{ + "intent": "select_shipping", + "shipping_method": "pickup", + "address": null +} + +Input: "Av. Corrientes 1234, Almagro" +Output: +{ + "intent": "provide_address", + "shipping_method": null, + "address": "Av. Corrientes 1234, Almagro" +} + +Input: "delivery a Palermo, calle Honduras 5000" +Output: +{ + "intent": "select_shipping", + "shipping_method": "delivery", + "address": "Palermo, calle Honduras 5000" +} + +Input: "1" (si el contexto indica que 1=delivery) +Output: +{ + "intent": "select_shipping", + "shipping_method": "delivery", + "address": null +} + +FORMATO JSON: +{ + "intent": "select_shipping|provide_address", + "shipping_method": "delivery" | "pickup" | null, + "address": "texto de dirección" | null +} \ No newline at end of file diff --git a/src/modules/3-turn-engine/nlu/humanFallback.js b/src/modules/3-turn-engine/nlu/humanFallback.js new file mode 100644 index 0000000..38ab432 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/humanFallback.js @@ -0,0 +1,164 @@ +/** + * Human Fallback - Lógica para escalar conversaciones a humanos + * + * Se activa cuando: + * - No se encuentra un producto en el catálogo + * - El NLU tiene baja confianza + * - Casos especiales que requieren atención humana + */ + +import { ConversationState } from "../fsm.js"; +import { createEmptyOrder } from "../orderModel.js"; + +/** + * Crea una respuesta de takeover para cuando no se encuentra un producto + * + * @param {Object} params + * @param {string} params.pendingQuery - La query/producto que no se encontró + * @param {Object} params.order - Estado actual del pedido + * @param {Object} params.context - Contexto adicional para el humano + * @returns {Object} Resultado con plan y decision para el pipeline + */ +export function createHumanTakeoverResponse({ pendingQuery, order, context = {} }) { + const currentOrder = order || createEmptyOrder(); + + // Mensaje amigable para el usuario + const reply = `Dejame consultar con el equipo sobre "${pendingQuery}". Te respondo en un momento.`; + + return { + plan: { + reply, + next_state: ConversationState.AWAITING_HUMAN, + intent: "human_takeover", + missing_fields: ["human_response"], + order_action: "none", + }, + decision: { + actions: [ + { + type: "request_human_takeover", + payload: { + pending_query: pendingQuery, + reason: "product_not_found", + context_snapshot: { + order: currentOrder, + ...context, + }, + }, + }, + ], + order: currentOrder, + audit: { + human_takeover_requested: true, + pending_query: pendingQuery, + }, + }, + }; +} + +/** + * Verifica si debería escalar a humano basado en los resultados del catálogo + * + * @param {Object} params + * @param {Array} params.candidates - Candidatos encontrados en el catálogo + * @param {string} params.query - Query original del usuario + * @param {number} params.confidenceThreshold - Umbral de confianza mínimo + * @returns {boolean} true si debería escalar a humano + */ +export function shouldEscalateToHuman({ candidates = [], query, confidenceThreshold = 0.3 }) { + // Si no hay candidatos, escalar + if (!candidates || candidates.length === 0) { + return true; + } + + // Si el mejor candidato tiene score muy bajo, escalar + const bestScore = candidates[0]?._score || 0; + if (bestScore < confidenceThreshold) { + return true; + } + + // Si la query es muy diferente al nombre del mejor candidato (por nombre) + // Esto es un heurístico simple para detectar confusiones + const bestName = (candidates[0]?.name || "").toLowerCase(); + const queryLower = (query || "").toLowerCase(); + + // Si no hay overlap significativo de palabras, podría ser confusión + const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2); + const nameWords = bestName.split(/\s+/).filter(w => w.length > 2); + + if (queryWords.length > 0 && nameWords.length > 0) { + const overlap = queryWords.filter(qw => + nameWords.some(nw => nw.includes(qw) || qw.includes(nw)) + ); + + // Si hay muy poco overlap y el score no es muy alto, escalar + if (overlap.length === 0 && bestScore < 0.7) { + return true; + } + } + + return false; +} + +/** + * Genera mensaje de respuesta cuando el humano responde al takeover + * + * @param {Object} params + * @param {string} params.humanResponse - Respuesta del humano + * @param {Object} params.order - Estado actual del pedido + * @returns {Object} Resultado para continuar el flujo normal + */ +export function createHumanResponseResult({ humanResponse, order }) { + const currentOrder = order || createEmptyOrder(); + + return { + plan: { + reply: humanResponse, + next_state: ConversationState.CART, // Volver al flujo normal + intent: "human_response", + missing_fields: [], + order_action: "none", + }, + decision: { + actions: [ + { + type: "human_response_sent", + payload: {}, + }, + ], + order: currentOrder, + audit: { + human_response_processed: true, + }, + }, + }; +} + +/** + * Verifica si el estado actual es AWAITING_HUMAN + */ +export function isAwaitingHuman(state) { + return state === ConversationState.AWAITING_HUMAN || state === "AWAITING_HUMAN"; +} + +/** + * Genera respuesta cuando el usuario envía mensaje mientras está en AWAITING_HUMAN + */ +export function createWaitingForHumanResponse({ order }) { + const currentOrder = order || createEmptyOrder(); + + return { + plan: { + reply: "Estoy esperando respuesta del equipo sobre tu consulta. Te aviso apenas tenga novedades.", + next_state: ConversationState.AWAITING_HUMAN, + intent: "other", + missing_fields: ["human_response"], + order_action: "none", + }, + decision: { + actions: [], + order: currentOrder, + audit: { still_waiting_human: true }, + }, + }; +} diff --git a/src/modules/3-turn-engine/nlu/index.js b/src/modules/3-turn-engine/nlu/index.js new file mode 100644 index 0000000..3a3bc95 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/index.js @@ -0,0 +1,189 @@ +/** + * NLU Modular - Punto de entrada principal + * + * Orquesta el Router + Specialists para procesar mensajes de usuario. + * Reemplaza a llmNluV3 con una arquitectura modular y prompts editables. + */ + +import { routerClassify, quickDomainDetect } from "./router.js"; +import { greetingNlu } from "./specialists/greeting.js"; +import { ordersNlu } from "./specialists/orders.js"; +import { shippingNlu } from "./specialists/shipping.js"; +import { paymentNlu } from "./specialists/payment.js"; +import { browseNlu } from "./specialists/browse.js"; +import { createEmptyNlu } from "./schemas.js"; + +// Re-exportar utilidades útiles +export { loadPrompt, invalidatePromptCache, AVAILABLE_VARIABLES } from "./promptLoader.js"; +export { PROMPT_KEYS, DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js"; + +/** + * Procesa un mensaje con el sistema NLU modular + * + * @param {Object} params + * @param {Object} params.input - Input del NLU + * @param {string} params.input.last_user_message - Mensaje del usuario + * @param {string} params.input.conversation_state - Estado actual de la conversación + * @param {Object} params.input.pending_context - Contexto de items pendientes + * @param {string} params.input.locale - Locale (default: es-AR) + * @param {number} params.tenantId - ID del tenant + * @param {Object} params.storeConfig - Configuración de la tienda (para variables) + * @returns {Object} { nlu, raw_text, model, usage, schema, validation, routing } + */ +export async function llmNluModular({ input, tenantId, storeConfig = {} } = {}) { + const text = input?.last_user_message || ""; + const state = input?.conversation_state || "IDLE"; + const startTime = Date.now(); + + // Tracking para debug + const routing = { + quick_detect: null, + router_result: null, + final_domain: null, + specialist_used: null, + }; + + try { + // 1) Quick detection: si es un caso obvio, evitar llamar al router LLM + const quickDomain = quickDomainDetect(text, state); + routing.quick_detect = quickDomain; + + // Casos donde podemos saltar el router: + // - Saludos simples + // - Números solos (1, 2) en estados SHIPPING/PAYMENT + // - Patrones muy claros + const skipRouter = shouldSkipRouter(text, state, quickDomain); + + let domain; + if (skipRouter) { + domain = quickDomain; + routing.router_result = { skipped: true, quick_domain: quickDomain }; + } else { + // 2) Router LLM: clasificar dominio + const routerResult = await routerClassify({ tenantId, text, state, storeConfig }); + domain = routerResult.domain; + routing.router_result = routerResult; + } + + routing.final_domain = domain; + + // 3) Dispatch al specialist correspondiente + let result; + + switch (domain) { + case "greeting": + routing.specialist_used = "greeting"; + result = await greetingNlu({ tenantId, text, storeConfig }); + break; + + case "orders": + routing.specialist_used = "orders"; + result = await ordersNlu({ tenantId, text, storeConfig }); + break; + + case "shipping": + routing.specialist_used = "shipping"; + result = await shippingNlu({ tenantId, text, storeConfig }); + break; + + case "payment": + routing.specialist_used = "payment"; + result = await paymentNlu({ tenantId, text, storeConfig }); + break; + + case "browse": + routing.specialist_used = "browse"; + result = await browseNlu({ tenantId, text, storeConfig }); + break; + + default: + // Fallback: usar orders como default si hay texto con posibles productos + routing.specialist_used = "orders_fallback"; + result = await ordersNlu({ tenantId, text, storeConfig }); + // Pero marcar como "other" si el resultado no es claro + if (result.nlu.confidence < 0.7) { + result.nlu.intent = "other"; + } + } + + // Agregar metadata de routing + result.routing = routing; + result.schema = "modular_v1"; + result.processing_time_ms = Date.now() - startTime; + + return result; + + } catch (error) { + console.error("[nluModular] Error:", error); + + // Fallback completo + const nlu = createEmptyNlu(); + nlu.intent = "other"; + nlu.confidence = 0; + + return { + nlu, + raw_text: "", + model: null, + usage: null, + schema: "modular_v1", + validation: { ok: false, error: error.message }, + routing: { ...routing, error: error.message }, + processing_time_ms: Date.now() - startTime, + }; + } +} + +/** + * Determina si podemos saltar el router LLM y usar quick detection + */ +function shouldSkipRouter(text, state, quickDomain) { + const t = String(text || "").trim(); + + // Saludos simples (sin productos) + if (quickDomain === "greeting" && t.length < 20) { + return true; + } + + // Números solos en estados específicos + if (/^[12]$/.test(t)) { + if (state === "SHIPPING" || state === "PAYMENT") { + return true; + } + } + + // "efectivo" o "tarjeta" solos en estado PAYMENT + if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) { + return true; + } + + // "delivery" o "retiro" solos en estado SHIPPING + if (state === "SHIPPING" && /^(delivery|retiro|buscar|sucursal)$/i.test(t)) { + return true; + } + + return false; +} + +/** + * Versión compatible con la firma de llmNluV3 + * Para usar con el feature flag sin cambiar mucho código + */ +export async function llmNluModularCompat({ input, model } = {}) { + // Extraer tenantId del input si está disponible, o usar 1 como default + // En producción, esto debería pasarse explícitamente + const tenantId = input?.tenantId || 1; + + // Construir storeConfig básico (en producción se cargaría de la DB) + const storeConfig = { + name: input?.store_name || "la carnicería", + botName: input?.bot_name || "Piaf", + hours: input?.store_hours || "", + address: input?.store_address || "", + }; + + return llmNluModular({ input, tenantId, storeConfig }); +} + +// Export default para compatibilidad +export default llmNluModular; diff --git a/src/modules/3-turn-engine/nlu/promptLoader.js b/src/modules/3-turn-engine/nlu/promptLoader.js new file mode 100644 index 0000000..4aa7515 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/promptLoader.js @@ -0,0 +1,204 @@ +/** + * Prompt Loader - Carga prompts de DB con fallback a defaults + * + * Características: + * - Cache en memoria con TTL configurable + * - Fallback a archivos default si no hay prompt custom + * - Reemplazo de variables básicas ({{store_name}}, etc.) + */ + +import { getActivePrompt } from "../../0-ui/db/promptsRepo.js"; +import { DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DEFAULTS_DIR = path.join(__dirname, "defaults"); + +// Cache en memoria +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutos + +/** + * Variables disponibles para reemplazo en prompts + */ +export const AVAILABLE_VARIABLES = [ + { key: "store_name", description: "Nombre del negocio", example: "Carnicería Don Pedro" }, + { key: "store_hours", description: "Horario de atención", example: "Lun-Sab 8-20hs" }, + { key: "store_address", description: "Dirección del local", example: "Av. Corrientes 1234" }, + { key: "store_phone", description: "Teléfono", example: "+54 11 1234-5678" }, + { key: "bot_name", description: "Nombre del bot", example: "Piaf" }, + { key: "current_date", description: "Fecha actual", example: "25 de enero" }, + { key: "customer_name", description: "Nombre del cliente (si lo tiene)", example: "Juan" }, + { key: "state", description: "Estado actual de la conversación", example: "CART" }, +]; + +/** + * Carga un prompt de la DB o usa el default + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.promptKey - Key del prompt ('router', 'greeting', 'orders', etc.) + * @param {Object} params.variables - Variables para reemplazar en el prompt + * @param {boolean} params.skipCache - Si es true, no usa cache + * @returns {Object} { content: string, model: string, isDefault: boolean, version: number|null } + */ +export async function loadPrompt({ tenantId, promptKey, variables = {}, skipCache = false }) { + const cacheKey = `${tenantId}:${promptKey}`; + + // Verificar cache + if (!skipCache) { + const cached = cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return applyVariables(cached.content, cached.model, cached.isDefault, cached.version, variables); + } + } + + // Intentar cargar de DB + let content, model, isDefault = false, version = null; + + try { + const dbPrompt = await getActivePrompt({ tenantId, promptKey }); + + if (dbPrompt) { + content = dbPrompt.content; + model = dbPrompt.model; + version = dbPrompt.version; + isDefault = false; + } else { + // Fallback a archivo default + const defaultContent = loadDefaultPrompt(promptKey); + content = defaultContent; + model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo"; + isDefault = true; + } + } catch (error) { + // Si falla la DB, usar default + console.error(`[promptLoader] Error loading prompt from DB: ${error.message}`); + const defaultContent = loadDefaultPrompt(promptKey); + content = defaultContent; + model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo"; + isDefault = true; + } + + // Guardar en cache + cache.set(cacheKey, { content, model, isDefault, version, timestamp: Date.now() }); + + return applyVariables(content, model, isDefault, version, variables); +} + +/** + * Carga el prompt default desde archivo + */ +export function loadDefaultPrompt(promptKey) { + const filePath = path.join(DEFAULTS_DIR, `${promptKey}.txt`); + + if (!fs.existsSync(filePath)) { + throw new Error(`Default prompt file not found: ${filePath}`); + } + + return fs.readFileSync(filePath, "utf-8"); +} + +/** + * Reemplaza variables en el contenido del prompt + */ +function applyVariables(content, model, isDefault, version, variables) { + let result = content; + + // Agregar fecha actual si no está en variables + if (!variables.current_date) { + const now = new Date(); + const months = ["enero", "febrero", "marzo", "abril", "mayo", "junio", + "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]; + variables.current_date = `${now.getDate()} de ${months[now.getMonth()]}`; + } + + // Reemplazar todas las variables + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{${key}}}`, "g"); + result = result.replace(regex, value || ""); + } + + // Limpiar variables no reemplazadas (dejar vacío) + result = result.replace(/\{\{[^}]+\}\}/g, ""); + + return { content: result, model, isDefault, version }; +} + +/** + * Invalida el cache de un prompt específico + */ +export function invalidatePromptCache(tenantId, promptKey) { + const cacheKey = `${tenantId}:${promptKey}`; + cache.delete(cacheKey); +} + +/** + * Invalida todo el cache de un tenant + */ +export function invalidateTenantCache(tenantId) { + for (const key of cache.keys()) { + if (key.startsWith(`${tenantId}:`)) { + cache.delete(key); + } + } +} + +/** + * Limpia todo el cache + */ +export function clearAllCache() { + cache.clear(); +} + +/** + * Obtiene estadísticas del cache (para debugging) + */ +export function getCacheStats() { + const entries = []; + const now = Date.now(); + + for (const [key, value] of cache.entries()) { + entries.push({ + key, + age: Math.round((now - value.timestamp) / 1000), + isExpired: now - value.timestamp >= CACHE_TTL, + isDefault: value.isDefault, + version: value.version, + }); + } + + return { + size: cache.size, + ttlSeconds: CACHE_TTL / 1000, + entries, + }; +} + +/** + * Pre-carga todos los prompts de un tenant (útil al inicio) + */ +export async function preloadPrompts({ tenantId, storeConfig = {} }) { + const promptKeys = ["router", "greeting", "orders", "shipping", "payment", "browse"]; + const results = {}; + + for (const key of promptKeys) { + try { + results[key] = await loadPrompt({ + tenantId, + promptKey: key, + variables: storeConfig, + skipCache: true + }); + } catch (error) { + console.error(`[promptLoader] Error preloading ${key}: ${error.message}`); + results[key] = { error: error.message }; + } + } + + return results; +} diff --git a/src/modules/3-turn-engine/nlu/router.js b/src/modules/3-turn-engine/nlu/router.js new file mode 100644 index 0000000..873eb34 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/router.js @@ -0,0 +1,175 @@ +/** + * Router NLU - Clasifica el dominio del mensaje + * + * Usa un prompt ligero para clasificar rápidamente el tipo de mensaje + * antes de enviarlo al specialist correspondiente. + */ + +import OpenAI from "openai"; +import { loadPrompt } from "./promptLoader.js"; +import { validateRouter, getValidationErrors } from "./schemas.js"; + +let _client = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + if (!_client) { + _client = new OpenAI({ apiKey }); + } + return _client; +} + +/** + * Extrae JSON de una respuesta de texto + */ +function extractJson(text) { + const s = String(text || ""); + const i = s.indexOf("{"); + const j = s.lastIndexOf("}"); + if (i >= 0 && j > i) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * Clasifica el dominio del mensaje + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.text - Mensaje del usuario + * @param {string} params.state - Estado actual de la conversación + * @param {Object} params.storeConfig - Config de la tienda (para variables) + * @returns {Object} { domain: string, raw_text: string, model: string } + */ +export async function routerClassify({ tenantId, text, state, storeConfig = {} }) { + const openai = getClient(); + + // Cargar prompt del router + const { content: systemPrompt, model } = await loadPrompt({ + tenantId, + promptKey: "router", + variables: { + state: state || "IDLE", + ...storeConfig, + }, + }); + + // Hacer la llamada al LLM + const response = await openai.chat.completions.create({ + model: model || "gpt-4o-mini", + temperature: 0.1, + max_tokens: 50, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const rawText = response?.choices?.[0]?.message?.content || ""; + let parsed = extractJson(rawText); + + // Validar respuesta + if (!parsed || !validateRouter(parsed)) { + // Fallback: intentar detectar por patrones simples + parsed = { domain: detectDomainByPatterns(text, state) }; + } + + return { + domain: parsed.domain || "other", + raw_text: rawText, + model: model, + usage: response?.usage || null, + }; +} + +/** + * Detección de dominio por patrones (fallback) + */ +function detectDomainByPatterns(text, state) { + const t = String(text || "").toLowerCase().trim(); + + // Greeting patterns (solo si no menciona productos) + const greetingPatterns = /^(hola|buenas?|buen d[ií]a|buenas tardes|buenas noches|qu[eé] tal|hey|hi|holis)\s*[!?.,]*$/i; + if (greetingPatterns.test(t)) { + return "greeting"; + } + + // Si el estado ya es SHIPPING o PAYMENT, priorizar esos dominios + if (state === "SHIPPING") { + if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) { + return "shipping"; + } + // Si parece una dirección (tiene números y palabras) + if (/\d+/.test(t) && /[a-záéíóú]{3,}/i.test(t)) { + return "shipping"; + } + } + + if (state === "PAYMENT") { + if (/efectivo|cash|tarjeta|link|transfer|mercadopago|mp|qr/i.test(t)) { + return "payment"; + } + // Números simples (1 o 2) en estado PAYMENT + if (/^[12]$/.test(t.trim())) { + return "payment"; + } + } + + // Orders patterns + const orderPatterns = [ + /\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i, + /\b(sacame|quitame|eliminame)\b/i, + /\b(qu[eé] tengo|qu[eé] anot[eé]|mi pedido|ver carrito)\b/i, + /\b(listo|eso es todo|cerrar|confirmar)\b/i, + /\d+\s*(kg|kilo|gramo|g|unidad)/i, // cantidad + unidad + ]; + if (orderPatterns.some(p => p.test(t))) { + return "orders"; + } + + // Browse patterns + const browsePatterns = [ + /\b(cu[aá]nto (sale|cuesta|est[aá]))\b/i, + /\b(precio de|precios)\b/i, + /\b(ten[eé]s|hay|vend[eé]s|tienen)\b/i, + /\b(qu[eé] me recomend[aá]s|recomendaci[oó]n)\b/i, + /\bpara\s+\d+\s*(personas?|comensales?)\b/i, + ]; + if (browsePatterns.some(p => p.test(t))) { + return "browse"; + } + + // Shipping patterns + if (/\b(delivery|env[ií]o|retiro|buscar|sucursal)\b/i.test(t)) { + return "shipping"; + } + + // Payment patterns + if (/\b(efectivo|tarjeta|link|transfer|mercadopago)\b/i.test(t)) { + return "payment"; + } + + // Default basado en estado + if (state === "CART") return "orders"; + if (state === "SHIPPING") return "shipping"; + if (state === "PAYMENT") return "payment"; + + return "other"; +} + +/** + * Detecta dominio solo por patrones (sin LLM) + * Útil para casos obvios o cuando queremos ahorrar latencia + */ +export function quickDomainDetect(text, state) { + return detectDomainByPatterns(text, state); +} diff --git a/src/modules/3-turn-engine/nlu/schemas.js b/src/modules/3-turn-engine/nlu/schemas.js new file mode 100644 index 0000000..70a0187 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/schemas.js @@ -0,0 +1,283 @@ +/** + * Schemas JSON para validación de respuestas NLU + */ + +import Ajv from "ajv"; + +const ajv = new Ajv({ allErrors: true, strict: true }); + +// ───────────────────────────────────────────────────────────── +// Schema: Router +// ───────────────────────────────────────────────────────────── + +export const RouterSchema = { + $id: "Router", + type: "object", + additionalProperties: false, + required: ["domain"], + properties: { + domain: { + type: "string", + enum: ["greeting", "orders", "shipping", "payment", "browse", "other"], + }, + }, +}; + +export const validateRouter = ajv.compile(RouterSchema); + +// ───────────────────────────────────────────────────────────── +// Schema: Greeting +// ───────────────────────────────────────────────────────────── + +export const GreetingSchema = { + $id: "Greeting", + type: "object", + additionalProperties: false, + required: ["intent", "reply"], + properties: { + intent: { type: "string", enum: ["greeting"] }, + reply: { type: "string", minLength: 1 }, + }, +}; + +export const validateGreeting = ajv.compile(GreetingSchema); + +// ───────────────────────────────────────────────────────────── +// Schema: Orders +// ───────────────────────────────────────────────────────────── + +export const OrdersSchema = { + $id: "Orders", + type: "object", + additionalProperties: false, + required: ["intent", "confidence"], + properties: { + intent: { + type: "string", + enum: ["add_to_cart", "remove_from_cart", "view_cart", "confirm_order"], + }, + confidence: { type: "number", minimum: 0, maximum: 1 }, + 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" }] }, + }, + }, + }, + ], + }, + product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, + quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, + unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, + }, +}; + +export const validateOrders = ajv.compile(OrdersSchema); + +// ───────────────────────────────────────────────────────────── +// Schema: Shipping +// ───────────────────────────────────────────────────────────── + +export const ShippingSchema = { + $id: "Shipping", + type: "object", + additionalProperties: false, + required: ["intent"], + properties: { + intent: { + type: "string", + enum: ["select_shipping", "provide_address"], + }, + shipping_method: { + anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }], + }, + address: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, +}; + +export const validateShipping = ajv.compile(ShippingSchema); + +// ───────────────────────────────────────────────────────────── +// Schema: Payment +// ───────────────────────────────────────────────────────────── + +export const PaymentSchema = { + $id: "Payment", + type: "object", + additionalProperties: false, + required: ["intent"], + properties: { + intent: { + type: "string", + enum: ["select_payment"], + }, + payment_method: { + anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }], + }, + }, +}; + +export const validatePayment = ajv.compile(PaymentSchema); + +// ───────────────────────────────────────────────────────────── +// Schema: Browse +// ───────────────────────────────────────────────────────────── + +export const BrowseSchema = { + $id: "Browse", + type: "object", + additionalProperties: false, + required: ["intent"], + properties: { + intent: { + type: "string", + enum: ["price_query", "browse", "recommend"], + }, + product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, + people_count: { anyOf: [{ type: "number" }, { type: "null" }] }, + event_type: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, +}; + +export const validateBrowse = ajv.compile(BrowseSchema); + +// ───────────────────────────────────────────────────────────── +// Schema: NLU Unificado (output final) +// ───────────────────────────────────────────────────────────── + +export const UnifiedNluSchema = { + $id: "UnifiedNlu", + type: "object", + additionalProperties: false, + required: ["intent", "confidence", "language", "entities", "needs"], + properties: { + intent: { + type: "string", + enum: [ + "price_query", "browse", "add_to_cart", "remove_from_cart", + "checkout", "confirm_order", "select_payment", "select_shipping", + "provide_address", "greeting", "recommend", "view_cart", "other" + ], + }, + confidence: { type: "number", minimum: 0, maximum: 1 }, + language: { type: "string" }, + entities: { + type: "object", + additionalProperties: false, + required: ["product_query", "quantity", "unit", "selection", "attributes", "preparation", "items"], + properties: { + product_query: { anyOf: [{ type: "string" }, { type: "null" }] }, + quantity: { anyOf: [{ type: "number" }, { type: "null" }] }, + unit: { anyOf: [{ type: "string", enum: ["kg", "g", "unidad"] }, { type: "null" }] }, + selection: { + anyOf: [ + { type: "null" }, + { + type: "object", + additionalProperties: false, + required: ["type", "value"], + properties: { + type: { type: "string", enum: ["index", "text", "sku"] }, + value: { type: "string", minLength: 1 }, + }, + }, + ], + }, + attributes: { type: "array", items: { type: "string" } }, + preparation: { type: "array", items: { type: "string" } }, + payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] }, + shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, + address: { anyOf: [{ type: "string" }, { type: "null" }] }, + 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" }] }, + }, + }, + }, + ], + }, + // Browse-specific + people_count: { anyOf: [{ type: "number" }, { type: "null" }] }, + event_type: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }, + needs: { + type: "object", + additionalProperties: false, + required: ["catalog_lookup", "knowledge_lookup"], + properties: { + catalog_lookup: { type: "boolean" }, + knowledge_lookup: { type: "boolean" }, + }, + }, + // Greeting-specific: reply del LLM + reply: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, +}; + +export const validateUnifiedNlu = ajv.compile(UnifiedNluSchema); + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/** + * Obtiene errores de validación formateados + */ +export function getValidationErrors(validate) { + const errors = validate.errors || []; + return errors.map((e) => ({ + path: e.instancePath, + message: e.message, + params: e.params, + })); +} + +/** + * Crea un NLU unificado vacío (fallback) + */ +export function createEmptyNlu() { + return { + intent: "other", + confidence: 0, + language: "es-AR", + entities: { + product_query: null, + quantity: null, + unit: null, + selection: null, + attributes: [], + preparation: [], + payment_method: null, + shipping_method: null, + address: null, + items: null, + people_count: null, + event_type: null, + }, + needs: { + catalog_lookup: false, + knowledge_lookup: false, + }, + reply: null, + }; +} diff --git a/src/modules/3-turn-engine/nlu/specialists/browse.js b/src/modules/3-turn-engine/nlu/specialists/browse.js new file mode 100644 index 0000000..d8e9f0d --- /dev/null +++ b/src/modules/3-turn-engine/nlu/specialists/browse.js @@ -0,0 +1,170 @@ +/** + * Browse Specialist - Consultas de catálogo, precios y recomendaciones + */ + +import OpenAI from "openai"; +import { loadPrompt } from "../promptLoader.js"; +import { validateBrowse, getValidationErrors, createEmptyNlu } from "../schemas.js"; + +let _client = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + if (!_client) { + _client = new OpenAI({ apiKey }); + } + return _client; +} + +function extractJson(text) { + const s = String(text || ""); + const i = s.indexOf("{"); + const j = s.lastIndexOf("}"); + if (i >= 0 && j > i) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * Detecta tipo de consulta por patrones simples + */ +function detectBrowseType(text) { + const t = String(text || "").toLowerCase(); + + // Price query + if (/\b(cu[aá]nto (sale|cuesta|est[aá])|precio|precios)\b/i.test(t)) { + return "price_query"; + } + + // Recommend + if (/\b(recomend[aá]|qu[eé] llevo|para \d+ personas?|para un asado)\b/i.test(t)) { + return "recommend"; + } + + // Browse (availability) + if (/\b(ten[eé]s|tienen|hay|vend[eé]s)\b/i.test(t)) { + return "browse"; + } + + return "browse"; +} + +/** + * Extrae número de personas del texto + */ +function extractPeopleCount(text) { + const t = String(text || ""); + + // "para X personas" + let match = /para\s+(\d+)\s*(personas?|comensales?|invitados?)?/i.exec(t); + if (match) return parseInt(match[1], 10); + + // "somos X" + match = /somos\s+(\d+)/i.exec(t); + if (match) return parseInt(match[1], 10); + + // "X personas" + match = /(\d+)\s*(personas?|comensales?)/i.exec(t); + if (match) return parseInt(match[1], 10); + + return null; +} + +/** + * Extrae producto mencionado (simple) + */ +function extractProductMention(text) { + const t = String(text || "").toLowerCase(); + + // Patrones comunes de preguntas + const patterns = [ + /(?:ten[eé]s|hay|vend[eé]s|precio de|cu[aá]nto (?:sale|cuesta) (?:el|la|los|las)?)\s*(.+?)(?:\?|$)/i, + /(.+?)\s*(?:tienen|hay|venden)\?/i, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(t); + if (match && match[1]) { + return match[1].trim(); + } + } + + return null; +} + +/** + * Procesa una consulta de catálogo + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.text - Mensaje del usuario + * @param {Object} params.storeConfig - Config de la tienda + * @returns {Object} NLU unificado + */ +export async function browseNlu({ tenantId, text, storeConfig = {} }) { + const openai = getClient(); + + // Cargar prompt de browse + const { content: systemPrompt, model } = await loadPrompt({ + tenantId, + promptKey: "browse", + variables: { + bot_name: storeConfig.botName || "Piaf", + store_name: storeConfig.name || "la carnicería", + ...storeConfig, + }, + }); + + // Hacer la llamada al LLM + const response = await openai.chat.completions.create({ + model: model || "gpt-4-turbo", + temperature: 0.2, + max_tokens: 200, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const rawText = response?.choices?.[0]?.message?.content || ""; + let parsed = extractJson(rawText); + + // Validar + if (!parsed || !validateBrowse(parsed)) { + // Fallback con detección por patrones + const browseType = detectBrowseType(text); + parsed = { + intent: browseType, + product_query: extractProductMention(text), + people_count: extractPeopleCount(text), + event_type: /asado/i.test(text) ? "asado" : null, + }; + } + + // Convertir a formato NLU unificado + const nlu = createEmptyNlu(); + nlu.intent = parsed.intent || "browse"; + nlu.confidence = 0.85; + nlu.entities.product_query = parsed.product_query || null; + nlu.entities.people_count = parsed.people_count || null; + nlu.entities.event_type = parsed.event_type || null; + nlu.needs.catalog_lookup = true; + nlu.needs.knowledge_lookup = nlu.intent === "recommend"; + + return { + nlu, + raw_text: rawText, + model, + usage: response?.usage || null, + validation: { ok: true }, + }; +} diff --git a/src/modules/3-turn-engine/nlu/specialists/greeting.js b/src/modules/3-turn-engine/nlu/specialists/greeting.js new file mode 100644 index 0000000..c2d8da4 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/specialists/greeting.js @@ -0,0 +1,100 @@ +/** + * Greeting Specialist - Maneja saludos con personalidad de carnicero argentino + */ + +import OpenAI from "openai"; +import { loadPrompt } from "../promptLoader.js"; +import { validateGreeting, getValidationErrors, createEmptyNlu } from "../schemas.js"; + +let _client = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + if (!_client) { + _client = new OpenAI({ apiKey }); + } + return _client; +} + +function extractJson(text) { + const s = String(text || ""); + const i = s.indexOf("{"); + const j = s.lastIndexOf("}"); + if (i >= 0 && j > i) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * Procesa un saludo y genera respuesta con personalidad + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.text - Mensaje del usuario + * @param {Object} params.storeConfig - Config de la tienda + * @returns {Object} NLU unificado con reply + */ +export async function greetingNlu({ tenantId, text, storeConfig = {} }) { + const openai = getClient(); + + // Cargar prompt de greeting + const { content: systemPrompt, model } = await loadPrompt({ + tenantId, + promptKey: "greeting", + variables: { + bot_name: storeConfig.botName || "Piaf", + store_name: storeConfig.name || "la carnicería", + store_hours: storeConfig.hours || "", + store_address: storeConfig.address || "", + store_phone: storeConfig.phone || "", + }, + }); + + // Hacer la llamada al LLM + const response = await openai.chat.completions.create({ + model: model || "gpt-4-turbo", + temperature: 0.7, // Un poco más de creatividad para saludos + max_tokens: 200, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const rawText = response?.choices?.[0]?.message?.content || ""; + let parsed = extractJson(rawText); + + // Validar respuesta + if (!parsed || !validateGreeting(parsed)) { + // Fallback con respuesta genérica + parsed = { + intent: "greeting", + reply: "¡Hola! ¿En qué te puedo ayudar?", + }; + } + + // Convertir a formato NLU unificado + const nlu = createEmptyNlu(); + nlu.intent = "greeting"; + nlu.confidence = 0.95; + nlu.reply = parsed.reply; + nlu.needs.catalog_lookup = false; + nlu.needs.knowledge_lookup = false; + + return { + nlu, + raw_text: rawText, + model, + usage: response?.usage || null, + validation: { ok: true }, + }; +} diff --git a/src/modules/3-turn-engine/nlu/specialists/orders.js b/src/modules/3-turn-engine/nlu/specialists/orders.js new file mode 100644 index 0000000..4905f89 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/specialists/orders.js @@ -0,0 +1,162 @@ +/** + * Orders Specialist - Extracción de productos y cantidades + * + * El specialist más importante: maneja add_to_cart, remove_from_cart, + * view_cart, confirm_order con soporte para multi-items. + */ + +import OpenAI from "openai"; +import { loadPrompt } from "../promptLoader.js"; +import { validateOrders, getValidationErrors, createEmptyNlu } from "../schemas.js"; + +let _client = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + if (!_client) { + _client = new OpenAI({ apiKey }); + } + return _client; +} + +function extractJson(text) { + const s = String(text || ""); + const i = s.indexOf("{"); + const j = s.lastIndexOf("}"); + if (i >= 0 && j > i) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * Normaliza unidades a formato estándar + */ +function normalizeUnit(unit) { + if (!unit) return null; + const u = String(unit).toLowerCase().trim(); + if (["kg", "kilo", "kilos", "kilogramo", "kilogramos"].includes(u)) return "kg"; + if (["g", "gr", "gramo", "gramos"].includes(u)) return "g"; + if (["unidad", "unidades", "u", "un"].includes(u)) return "unidad"; + return null; +} + +/** + * Normaliza items extraídos + */ +function normalizeItems(items) { + if (!Array.isArray(items) || items.length === 0) return null; + + return items + .filter(item => item && item.product_query) + .map(item => ({ + product_query: String(item.product_query || "").trim(), + quantity: typeof item.quantity === "number" ? item.quantity : null, + unit: normalizeUnit(item.unit), + })) + .filter(item => item.product_query.length > 0); +} + +/** + * Procesa un mensaje de pedido + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.text - Mensaje del usuario + * @param {Object} params.storeConfig - Config de la tienda + * @returns {Object} NLU unificado + */ +export async function ordersNlu({ tenantId, text, storeConfig = {} }) { + const openai = getClient(); + + // Cargar prompt de orders + const { content: systemPrompt, model } = await loadPrompt({ + tenantId, + promptKey: "orders", + variables: storeConfig, + }); + + // Hacer la llamada al LLM + const response = await openai.chat.completions.create({ + model: model || "gpt-4-turbo", + temperature: 0.1, // Baja temperatura para extracción precisa + max_tokens: 500, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const rawText = response?.choices?.[0]?.message?.content || ""; + let parsed = extractJson(rawText); + + // Intentar validar + let validationOk = false; + if (parsed && validateOrders(parsed)) { + validationOk = true; + } else if (parsed) { + // Intentar normalizar respuesta parcialmente válida + parsed = { + intent: parsed.intent || "add_to_cart", + confidence: parsed.confidence || 0.8, + items: parsed.items || null, + product_query: parsed.product_query || null, + quantity: parsed.quantity || null, + unit: parsed.unit || null, + }; + validationOk = true; + } else { + // Fallback total + parsed = { + intent: "add_to_cart", + confidence: 0.5, + items: null, + product_query: text.length < 50 ? text : null, + quantity: null, + unit: null, + }; + } + + // Normalizar items - SIEMPRE convertir a array + let normalizedItems = normalizeItems(parsed.items); + + // Si no hay items pero hay product_query en raíz, convertir a array + if ((!normalizedItems || normalizedItems.length === 0) && parsed.product_query) { + normalizedItems = [{ + product_query: String(parsed.product_query).trim(), + quantity: typeof parsed.quantity === "number" ? parsed.quantity : null, + unit: normalizeUnit(parsed.unit), + }]; + } + + // Convertir a formato NLU unificado + const nlu = createEmptyNlu(); + nlu.intent = parsed.intent || "add_to_cart"; + nlu.confidence = parsed.confidence || 0.8; + + // Entities - siempre usar items[], nunca campos individuales + nlu.entities.items = normalizedItems || []; + nlu.entities.product_query = null; // Deprecado, usar items[] + nlu.entities.quantity = null; + nlu.entities.unit = null; + + // Needs + nlu.needs.catalog_lookup = ["add_to_cart", "remove_from_cart"].includes(nlu.intent); + nlu.needs.knowledge_lookup = false; + + return { + nlu, + raw_text: rawText, + model, + usage: response?.usage || null, + validation: { ok: validationOk, errors: validationOk ? [] : getValidationErrors(validateOrders) }, + }; +} diff --git a/src/modules/3-turn-engine/nlu/specialists/payment.js b/src/modules/3-turn-engine/nlu/specialists/payment.js new file mode 100644 index 0000000..2c24a38 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/specialists/payment.js @@ -0,0 +1,135 @@ +/** + * Payment Specialist - Extracción de método de pago + */ + +import OpenAI from "openai"; +import { loadPrompt } from "../promptLoader.js"; +import { validatePayment, getValidationErrors, createEmptyNlu } from "../schemas.js"; + +let _client = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + if (!_client) { + _client = new OpenAI({ apiKey }); + } + return _client; +} + +function extractJson(text) { + const s = String(text || ""); + const i = s.indexOf("{"); + const j = s.lastIndexOf("}"); + if (i >= 0 && j > i) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * Detecta método de pago por patrones simples + */ +function detectPaymentMethod(text) { + const t = String(text || "").toLowerCase().trim(); + + // Números (asumiendo 1=efectivo, 2=link del contexto) + if (/^1$/.test(t)) return "cash"; + if (/^2$/.test(t)) return "link"; + + // Cash patterns + if (/\b(efectivo|cash|plata|billete|cuando (llega|llegue)|en mano)\b/i.test(t)) { + return "cash"; + } + + // Link patterns + if (/\b(tarjeta|link|transfer|qr|mercadopago|mp|d[eé]bito|cr[eé]dito)\b/i.test(t)) { + return "link"; + } + + return null; +} + +/** + * Procesa un mensaje de pago + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.text - Mensaje del usuario + * @param {Object} params.storeConfig - Config de la tienda + * @returns {Object} NLU unificado + */ +export async function paymentNlu({ tenantId, text, storeConfig = {} }) { + // Intentar detección rápida primero + const quickMethod = detectPaymentMethod(text); + + // Si es claramente un número o patrón simple, no llamar al LLM + if (quickMethod && text.trim().length < 30) { + const nlu = createEmptyNlu(); + nlu.intent = "select_payment"; + nlu.confidence = 0.9; + nlu.entities.payment_method = quickMethod; + + return { + nlu, + raw_text: "", + model: null, + usage: null, + validation: { ok: true, skipped_llm: true }, + }; + } + + const openai = getClient(); + + // Cargar prompt de payment + const { content: systemPrompt, model } = await loadPrompt({ + tenantId, + promptKey: "payment", + variables: storeConfig, + }); + + // Hacer la llamada al LLM + const response = await openai.chat.completions.create({ + model: model || "gpt-4o-mini", + temperature: 0.1, + max_tokens: 100, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const rawText = response?.choices?.[0]?.message?.content || ""; + let parsed = extractJson(rawText); + + // Validar + if (!parsed || !validatePayment(parsed)) { + // Fallback con detección por patrones + parsed = { + intent: "select_payment", + payment_method: quickMethod, + }; + } + + // Convertir a formato NLU unificado + const nlu = createEmptyNlu(); + nlu.intent = "select_payment"; + nlu.confidence = 0.85; + nlu.entities.payment_method = parsed.payment_method || null; + nlu.needs.catalog_lookup = false; + + return { + nlu, + raw_text: rawText, + model, + usage: response?.usage || null, + validation: { ok: true }, + }; +} diff --git a/src/modules/3-turn-engine/nlu/specialists/shipping.js b/src/modules/3-turn-engine/nlu/specialists/shipping.js new file mode 100644 index 0000000..52703e3 --- /dev/null +++ b/src/modules/3-turn-engine/nlu/specialists/shipping.js @@ -0,0 +1,157 @@ +/** + * Shipping Specialist - Extracción de método de envío y dirección + */ + +import OpenAI from "openai"; +import { loadPrompt } from "../promptLoader.js"; +import { validateShipping, getValidationErrors, createEmptyNlu } from "../schemas.js"; + +let _client = null; + +function getClient() { + const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + if (!_client) { + _client = new OpenAI({ apiKey }); + } + return _client; +} + +function extractJson(text) { + const s = String(text || ""); + const i = s.indexOf("{"); + const j = s.lastIndexOf("}"); + if (i >= 0 && j > i) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + return null; + } + } + return null; +} + +/** + * Detecta método de envío por patrones simples + */ +function detectShippingMethod(text) { + const t = String(text || "").toLowerCase(); + + // Números (asumiendo 1=delivery, 2=pickup del contexto) + if (/^1$/.test(t.trim())) return "delivery"; + if (/^2$/.test(t.trim())) return "pickup"; + + // Delivery patterns + if (/\b(delivery|env[ií]o|enviar|traigan|llev|domicilio)\b/i.test(t)) { + return "delivery"; + } + + // Pickup patterns + if (/\b(retiro|retirar|buscar|paso|sucursal|local)\b/i.test(t)) { + return "pickup"; + } + + return null; +} + +/** + * Detecta si el texto parece una dirección + */ +function looksLikeAddress(text) { + const t = String(text || "").trim(); + + // Tiene números y letras, más de 10 caracteres + if (t.length > 10 && /\d/.test(t) && /[a-záéíóú]/i.test(t)) { + return true; + } + + // Menciona calles, avenidas, barrios + if (/\b(calle|av|avenida|entre|esquina|piso|depto|dto|barrio)\b/i.test(t)) { + return true; + } + + return false; +} + +/** + * Procesa un mensaje de shipping + * + * @param {Object} params + * @param {number} params.tenantId - ID del tenant + * @param {string} params.text - Mensaje del usuario + * @param {Object} params.storeConfig - Config de la tienda + * @returns {Object} NLU unificado + */ +export async function shippingNlu({ tenantId, text, storeConfig = {} }) { + const openai = getClient(); + + // Intentar detección rápida primero + const quickMethod = detectShippingMethod(text); + const isAddress = looksLikeAddress(text); + + // Si es claramente un número o patrón simple, no llamar al LLM + if (quickMethod && !isAddress && text.trim().length < 20) { + const nlu = createEmptyNlu(); + nlu.intent = "select_shipping"; + nlu.confidence = 0.9; + nlu.entities.shipping_method = quickMethod; + + return { + nlu, + raw_text: "", + model: null, + usage: null, + validation: { ok: true, skipped_llm: true }, + }; + } + + // Cargar prompt de shipping + const { content: systemPrompt, model } = await loadPrompt({ + tenantId, + promptKey: "shipping", + variables: storeConfig, + }); + + // Hacer la llamada al LLM + const response = await openai.chat.completions.create({ + model: model || "gpt-4o-mini", + temperature: 0.1, + max_tokens: 150, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const rawText = response?.choices?.[0]?.message?.content || ""; + let parsed = extractJson(rawText); + + // Validar + if (!parsed || !validateShipping(parsed)) { + // Fallback con detección por patrones + parsed = { + intent: isAddress ? "provide_address" : "select_shipping", + shipping_method: quickMethod, + address: isAddress ? text.trim() : null, + }; + } + + // Convertir a formato NLU unificado + const nlu = createEmptyNlu(); + nlu.intent = parsed.intent || "select_shipping"; + nlu.confidence = 0.85; + nlu.entities.shipping_method = parsed.shipping_method || null; + nlu.entities.address = parsed.address || null; + nlu.needs.catalog_lookup = false; + + return { + nlu, + raw_text: rawText, + model, + usage: response?.usage || null, + validation: { ok: true }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers.js b/src/modules/3-turn-engine/stateHandlers.js index ac79901..3078bb2 100644 --- a/src/modules/3-turn-engine/stateHandlers.js +++ b/src/modules/3-turn-engine/stateHandlers.js @@ -19,6 +19,7 @@ import { formatOptionsForDisplay, } from "./orderModel.js"; import { handleRecommend } from "./recommendations.js"; +import { getProductQtyRules } from "../0-ui/db/repo.js"; // ───────────────────────────────────────────────────────────── // Utilidades @@ -356,6 +357,62 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI }; } if (nextPending.status === PendingStatus.NEEDS_QUANTITY) { + // Detectar "para X personas" en el texto original ANTES de preguntar cantidad + const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || + /\bpara\s+(\d+)\b/i.exec(text || "") || + /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); + + if (personasMatch && nextPending.selected_woo_id) { + const peopleCount = parseInt(personasMatch[1], 10); + + if (peopleCount > 0 && peopleCount <= 100) { + // Buscar reglas de cantidad por persona para este producto + let qtyRules = []; + try { + qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id }); + } catch (e) { + audit.qty_rules_error = e?.message; + } + + // Calcular cantidad recomendada + let calculatedQty; + let calculatedUnit = nextPending.selected_unit || "kg"; + const rule = qtyRules[0]; + + if (rule && rule.qty_per_person > 0) { + calculatedQty = rule.qty_per_person * peopleCount; + calculatedUnit = rule.unit || calculatedUnit; + audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount }; + } else { + // Fallback: 0.3 kg por persona para carnes + calculatedQty = 0.3 * peopleCount; + audit.qty_fallback = { default_per_person: 0.3, people: peopleCount }; + } + + // Actualizar el pending item y mover al cart + const updatedOrder = updatePendingItem(currentOrder, nextPending.id, { + qty: calculatedQty, + unit: calculatedUnit, + status: PendingStatus.READY, + }); + + const finalOrder = moveReadyToCart(updatedOrder); + const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; + + return { + plan: { + reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}. Ya lo anoté. ¿Algo más?`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: [], + order_action: "add_to_cart", + }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + }; + } + } + + // Si no hay "para X personas", preguntar cantidad normalmente const unitQuestion = unitAskFor(nextPending.selected_unit || "kg"); return { plan: { @@ -840,6 +897,73 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending }; } + // Detectar "para X personas" y calcular cantidad automáticamente + const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || + /\bpara\s+(\d+)\b/i.exec(text || "") || + /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); + + if (personasMatch && pendingItem.selected_woo_id) { + const peopleCount = parseInt(personasMatch[1], 10); + + if (peopleCount > 0 && peopleCount <= 100) { + // Buscar reglas de cantidad por persona para este producto + let qtyRules = []; + try { + qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id }); + } catch (e) { + audit.qty_rules_error = e?.message; + } + + // Buscar regla para evento "asado" o genérica (null) + const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") || + qtyRules.find(r => r.event_type === null && r.person_type === "adult") || + qtyRules.find(r => r.person_type === "adult") || + qtyRules[0]; + + let calculatedQty; + let calculatedUnit = pendingItem.selected_unit || "kg"; + + if (rule && rule.qty_per_person > 0) { + // Usar regla de BD + calculatedQty = rule.qty_per_person * peopleCount; + calculatedUnit = rule.unit || calculatedUnit; + audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit }; + } else { + // Fallback: 300g por persona para productos por peso + const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; + calculatedQty = fallbackPerPerson * peopleCount; + audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit }; + } + + // Redondear a 1 decimal para kg, entero para unidades + if (calculatedUnit === "unit") { + calculatedQty = Math.ceil(calculatedQty); + } else { + calculatedQty = Math.round(calculatedQty * 10) / 10; + } + + const updatedOrder = updatePendingItem(order, pendingItem.id, { + qty: calculatedQty, + unit: calculatedUnit, + status: PendingStatus.READY, + }); + + const finalOrder = moveReadyToCart(updatedOrder); + const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; + + return { + plan: { + reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}. Ya lo anoté. ¿Algo más?`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: [], + order_action: "add_to_cart", + }, + decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, + }; + } + } + // No entendió cantidad const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); return { diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index 66105d6..07d18a3 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -3,9 +3,12 @@ * * Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS * Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado. + * + * Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular. */ import { llmNluV3 } from "./openai.js"; +import { llmNluModular } from "./nlu/index.js"; import { ConversationState, shouldReturnToCart, safeNextState } from "./fsm.js"; import { migrateOldContext, createEmptyOrder } from "./orderModel.js"; import { @@ -15,6 +18,10 @@ import { handlePaymentState, handleWaitingState, } from "./stateHandlers.js"; +import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; + +// Feature flag para NLU modular +const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; /** * Genera un resumen corto del historial para el NLU @@ -58,7 +65,7 @@ export async function runTurnV3({ const normalizedState = normalizeState(prev_state); // ───────────────────────────────────────────────────────────── - // NLU + // NLU (con feature flag para sistema modular) // ───────────────────────────────────────────────────────────── const nluInput = { @@ -73,8 +80,37 @@ export async function runTurnV3({ locale: "es-AR", }; - const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput }); - audit.nlu = { raw_text, model, usage, validation, parsed: nlu }; + let nluResult; + + if (USE_MODULAR_NLU) { + // Nuevo sistema NLU modular con prompts editables + // Cargar configuración del tenant desde la DB + const storeConfig = await getStoreConfig({ tenantId }); + + nluResult = await llmNluModular({ input: nluInput, tenantId, storeConfig }); + audit.nlu = { + raw_text: nluResult.raw_text, + model: nluResult.model, + usage: nluResult.usage, + validation: nluResult.validation, + parsed: nluResult.nlu, + routing: nluResult.routing, + schema: "modular_v1", + }; + } else { + // Sistema NLU clásico + nluResult = await llmNluV3({ input: nluInput }); + audit.nlu = { + raw_text: nluResult.raw_text, + model: nluResult.model, + usage: nluResult.usage, + validation: nluResult.validation, + parsed: nluResult.nlu, + schema: "v3", + }; + } + + const nlu = nluResult.nlu; // ───────────────────────────────────────────────────────────── // Dispatcher por estado @@ -90,7 +126,8 @@ export async function runTurnV3({ }; // Regla universal: si quiere agregar productos, volver a CART - if (shouldReturnToCart(normalizedState, nlu)) { + const returnToCart = shouldReturnToCart(normalizedState, nlu, text); + if (returnToCart) { const result = await handleCartState({ ...handlerParams, fromIdle: false }); return formatResult(result, prev_context); } diff --git a/src/modules/4-woo-orders/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js index 86e1096..598330e 100644 --- a/src/modules/4-woo-orders/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -125,7 +125,7 @@ async function buildLineItems({ tenantId, basket }) { const lineItems = []; for (const it of items) { const productId = Number(it.product_id); - const unit = String(it.unit); + const unit = String(it.unit).toLowerCase(); const qty = Number(it.quantity); if (!productId || !Number.isFinite(qty) || qty <= 0) continue; const pricePerKg = await getWooProductPrice({ tenantId, productId }); @@ -144,8 +144,10 @@ async function buildLineItems({ tenantId, basket }) { continue; } - // Carne por gramos (enteros): quantity=1 y total calculado en base a ARS/kg - const grams = Math.round(qty); + // Carne por peso: convertir a gramos + // Si qty < 100, asumir que viene en kg (ej: 1.5 kg) + // Si qty >= 100, asumir que ya viene en gramos (ej: 1500 g) + const grams = qty < 100 ? Math.round(qty * 1000) : Math.round(qty); const kilos = grams / 1000; const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null; lineItems.push({ @@ -165,6 +167,13 @@ async function buildLineItems({ tenantId, basket }) { function mapAddress(address) { if (!address || typeof address !== "object") return null; + // Generar email fallback si no hay uno válido (usa formato wa_chat_id) + let email = address.email || ""; + if (!email || !email.includes("@")) { + const phone = address.phone || ""; + // Formato: {phone}@s.whatsapp.net (igual que wa_chat_id) + email = phone ? `${phone.replace(/[^0-9]/g, "")}@s.whatsapp.net` : `anon-${Date.now()}@s.whatsapp.net`; + } return { first_name: address.first_name || "", last_name: address.last_name || "", @@ -175,7 +184,7 @@ function mapAddress(address) { postcode: address.postcode || "", country: address.country || "AR", phone: address.phone || "", - email: address.email || "", + email, }; }