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
+
+
+
+
+
+ `;
+ }
+
+ 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("")}
+
+
+ ` : ""}
+
+
+
+
+
+
+
+
Probar Prompt
+
+
+
+
+
+
+
+ `;
+
+ // 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
+
+
+
+
+
+
+
+
+
+
+
+ Delivery (Envío a domicilio)
+
+
+
+
+
Delivery habilitado
+
+
+
+
+
+
+ ${DAYS.map(d => `
+
+ `).join("")}
+
+
+
+
+ Horario:
+
+ a
+
+ (formato 24hs)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+ 0
+
+
+
+
+
+
+ `;
+ }
+
+ 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,
};
}