modularizado de prompts
This commit is contained in:
@@ -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"}
|
|
||||||
50
db/migrations/20260125100000_prompt_templates.sql
Normal file
50
db/migrations/20260125100000_prompt_templates.sql
Normal file
@@ -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;
|
||||||
56
db/migrations/20260125110000_human_takeovers.sql
Normal file
56
db/migrations/20260125110000_human_takeovers.sql
Normal file
@@ -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;
|
||||||
58
db/migrations/20260125120000_tenant_settings.sql
Normal file
58
db/migrations/20260125120000_tenant_settings.sql
Normal file
@@ -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;
|
||||||
@@ -10,6 +10,9 @@ import "./components/recommendations-crud.js";
|
|||||||
import "./components/quantities-crud.js";
|
import "./components/quantities-crud.js";
|
||||||
import "./components/orders-crud.js";
|
import "./components/orders-crud.js";
|
||||||
import "./components/test-panel.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 { connectSSE } from "./lib/sse.js";
|
||||||
import { initRouter } from "./lib/router.js";
|
import { initRouter } from "./lib/router.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { emit, on } from "../lib/bus.js";
|
import { emit, on } from "../lib/bus.js";
|
||||||
import { navigateToView, navigateToItem } from "../lib/router.js";
|
import { navigateToView, navigateToItem } from "../lib/router.js";
|
||||||
|
import { api } from "../lib/api.js";
|
||||||
|
|
||||||
class OpsShell extends HTMLElement {
|
class OpsShell extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -7,6 +8,7 @@ class OpsShell extends HTMLElement {
|
|||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
this._currentView = "chat";
|
this._currentView = "chat";
|
||||||
this._currentParams = {};
|
this._currentParams = {};
|
||||||
|
this._takeoverCount = 0;
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
@@ -22,6 +24,18 @@ class OpsShell extends HTMLElement {
|
|||||||
.spacer { flex:1; }
|
.spacer { flex:1; }
|
||||||
.status { font-size:12px; color:var(--muted); }
|
.status { font-size:12px; color:var(--muted); }
|
||||||
|
|
||||||
|
/* Notification bell */
|
||||||
|
.notification-bell { position:relative; cursor:pointer; padding:8px; margin-right:12px; }
|
||||||
|
.notification-bell svg { width:20px; height:20px; fill:var(--muted); transition:fill .15s; }
|
||||||
|
.notification-bell:hover svg { fill:var(--text); }
|
||||||
|
.notification-bell.has-pending svg { fill:#f39c12; }
|
||||||
|
.notification-bell .badge {
|
||||||
|
position:absolute; top:2px; right:2px;
|
||||||
|
background:#e74c3c; color:#fff;
|
||||||
|
font-size:10px; padding:2px 6px; border-radius:10px;
|
||||||
|
font-weight:700; min-width:18px; text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Layout para chat activo (2 columnas: burbujas + inspector) */
|
/* Layout para chat activo (2 columnas: burbujas + inspector) */
|
||||||
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
|
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
|
||||||
.col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
|
.col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
|
||||||
@@ -48,9 +62,15 @@ class OpsShell extends HTMLElement {
|
|||||||
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
|
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
|
||||||
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
|
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
|
||||||
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
|
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
|
||||||
|
<a class="nav-btn" href="/config-prompts" data-view="prompts">Prompts</a>
|
||||||
|
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
|
||||||
<a class="nav-btn" href="/test" data-view="test">Test</a>
|
<a class="nav-btn" href="/test" data-view="test">Test</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<div class="notification-bell" id="notificationBell" title="Takeovers pendientes">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>
|
||||||
|
<span class="badge" id="takeoverBadge" style="display:none;">0</span>
|
||||||
|
</div>
|
||||||
<div class="status" id="sseStatus">SSE: connecting…</div>
|
<div class="status" id="sseStatus">SSE: connecting…</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -109,6 +129,24 @@ class OpsShell extends HTMLElement {
|
|||||||
<test-panel></test-panel>
|
<test-panel></test-panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="viewPrompts" class="view">
|
||||||
|
<div class="layout-crud">
|
||||||
|
<prompts-crud></prompts-crud>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewTakeovers" class="view">
|
||||||
|
<div class="layout-crud">
|
||||||
|
<takeovers-crud></takeovers-crud>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewSettings" class="view">
|
||||||
|
<div class="layout-crud">
|
||||||
|
<settings-crud></settings-crud>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -138,12 +176,51 @@ class OpsShell extends HTMLElement {
|
|||||||
this.setView(view, {}, { updateUrl: true });
|
this.setView(view, {}, { updateUrl: true });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification bell click
|
||||||
|
const bell = this.shadowRoot.getElementById("notificationBell");
|
||||||
|
bell.onclick = () => {
|
||||||
|
this.setView("takeovers", {}, { updateUrl: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start polling for takeovers
|
||||||
|
this.pollTakeovers();
|
||||||
|
this._pollInterval = setInterval(() => this.pollTakeovers(), 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this._unsub?.();
|
this._unsub?.();
|
||||||
this._unsubSwitch?.();
|
this._unsubSwitch?.();
|
||||||
this._unsubRouter?.();
|
this._unsubRouter?.();
|
||||||
|
if (this._pollInterval) clearInterval(this._pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollTakeovers() {
|
||||||
|
try {
|
||||||
|
const data = await api.takeovers({ limit: 1 });
|
||||||
|
const count = data.pending_count || (data.items?.length || 0);
|
||||||
|
this._takeoverCount = count;
|
||||||
|
this.updateTakeoverBadge(count);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - don't break the UI
|
||||||
|
console.debug("Error polling takeovers:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTakeoverBadge(count) {
|
||||||
|
const badge = this.shadowRoot.getElementById("takeoverBadge");
|
||||||
|
const bell = this.shadowRoot.getElementById("notificationBell");
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count > 99 ? "99+" : count;
|
||||||
|
badge.style.display = "inline";
|
||||||
|
bell.classList.add("has-pending");
|
||||||
|
bell.title = `${count} takeover(s) pendiente(s)`;
|
||||||
|
} else {
|
||||||
|
badge.style.display = "none";
|
||||||
|
bell.classList.remove("has-pending");
|
||||||
|
bell.title = "No hay takeovers pendientes";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setView(viewName, params = {}, { updateUrl = true } = {}) {
|
setView(viewName, params = {}, { updateUrl = true } = {}) {
|
||||||
|
|||||||
471
public/components/prompts-crud.js
Normal file
471
public/components/prompts-crud.js
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import { api } from "../lib/api.js";
|
||||||
|
import { on } from "../lib/bus.js";
|
||||||
|
|
||||||
|
const PROMPT_LABELS = {
|
||||||
|
router: "Router (clasificador de dominio)",
|
||||||
|
greeting: "Saludos",
|
||||||
|
orders: "Pedidos",
|
||||||
|
shipping: "Envio/Retiro",
|
||||||
|
payment: "Pago",
|
||||||
|
browse: "Consultas de catalogo",
|
||||||
|
};
|
||||||
|
|
||||||
|
class PromptsCrud extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this.items = [];
|
||||||
|
this.selected = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.versions = [];
|
||||||
|
this.availableVariables = [];
|
||||||
|
this.availableModels = [];
|
||||||
|
this.currentSettings = {}; // Valores actuales de las variables
|
||||||
|
this.testResult = null;
|
||||||
|
this.testLoading = false;
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
|
.container { display:grid; grid-template-columns:280px 1fr; gap:16px; height:100%; }
|
||||||
|
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
|
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
|
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||||
|
textarea { resize:vertical; min-height:200px; font-family:monospace; font-size:12px; line-height:1.5; }
|
||||||
|
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
|
button:hover { background:#1a5fd0; }
|
||||||
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
button.secondary { background:#253245; }
|
||||||
|
button.secondary:hover { background:#2d3e52; }
|
||||||
|
button.danger { background:#e74c3c; }
|
||||||
|
button.danger:hover { background:#c0392b; }
|
||||||
|
button.small { padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
|
.list { flex:1; overflow-y:auto; }
|
||||||
|
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
|
.item:hover { border-color:#1f6feb; }
|
||||||
|
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||||
|
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:14px; }
|
||||||
|
.item-meta { font-size:11px; color:#8aa0b5; }
|
||||||
|
.item-meta .default { color:#2ecc71; }
|
||||||
|
.item-meta .custom { color:#f39c12; }
|
||||||
|
|
||||||
|
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||||
|
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||||
|
.field { }
|
||||||
|
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
||||||
|
|
||||||
|
.actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||||
|
|
||||||
|
.variables-list { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||||
|
.var-item { display:inline-flex; align-items:center; gap:4px; background:#0f1520; border:1px solid #253245; border-radius:4px; padding:2px 4px 2px 2px; }
|
||||||
|
.var-btn { background:#253245; border:none; color:#8aa0b5; padding:4px 8px; border-radius:3px; font-size:11px; cursor:pointer; font-family:monospace; }
|
||||||
|
.var-btn:hover { background:#1f6feb; color:#fff; }
|
||||||
|
.var-value { font-size:10px; color:#6c7a89; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
|
||||||
|
.versions-list { max-height:150px; overflow-y:auto; margin-top:8px; }
|
||||||
|
.version-item { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; background:#0f1520; border-radius:4px; margin-bottom:4px; font-size:12px; }
|
||||||
|
.version-item.active { border-left:3px solid #2ecc71; }
|
||||||
|
.version-item .ver { color:#e7eef7; }
|
||||||
|
.version-item .date { color:#8aa0b5; font-size:10px; }
|
||||||
|
|
||||||
|
.test-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:16px; }
|
||||||
|
.test-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
||||||
|
.test-result { background:#0a0e14; border:1px solid #1e2a3a; border-radius:6px; padding:12px; margin-top:12px; font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
||||||
|
.test-result.error { border-color:#e74c3c; color:#e74c3c; }
|
||||||
|
.test-result.success { border-color:#2ecc71; }
|
||||||
|
.test-meta { font-size:10px; color:#8aa0b5; margin-top:8px; }
|
||||||
|
|
||||||
|
.row { display:flex; gap:12px; align-items:flex-end; }
|
||||||
|
.row .field { flex:1; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">Prompts del Sistema</div>
|
||||||
|
<div class="list" id="list">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title" id="formTitle">Editor de Prompt</div>
|
||||||
|
<div class="form" id="form">
|
||||||
|
<div class="form-empty">Selecciona un prompt para editarlo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="loading">Cargando...</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.items.length) {
|
||||||
|
list.innerHTML = `<div class="loading">No se encontraron prompts</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="item-name">${label}</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="${statusClass}">${statusText}</span>
|
||||||
|
${item.model ? ` | ${item.model}` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<div class="form-empty">Selecciona un prompt para editarlo</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Modelo LLM</label>
|
||||||
|
<select id="modelSelect">
|
||||||
|
${this.availableModels.map(m => `<option value="${m}" ${m === model ? "selected" : ""}>${m}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:0">
|
||||||
|
<label> </label>
|
||||||
|
<button id="resetBtn" class="secondary">Reset a Default</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="flex:1; display:flex; flex-direction:column;">
|
||||||
|
<label>Contenido del Prompt</label>
|
||||||
|
<textarea id="contentInput" style="flex:1; min-height:250px;">${this.escapeHtml(content)}</textarea>
|
||||||
|
<div class="field-hint">
|
||||||
|
Variables disponibles (click para insertar):
|
||||||
|
<div class="variables-list" id="variablesList">
|
||||||
|
${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 `<span class="var-item">
|
||||||
|
<button class="var-btn" data-var="${key}" title="${desc}">{{${key}}}</button>
|
||||||
|
<span class="var-value" title="${this.escapeHtml(value)}">${this.escapeHtml(displayValue)}</span>
|
||||||
|
</span>`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.versions.length > 0 ? `
|
||||||
|
<div class="field">
|
||||||
|
<label>Historial de Versiones</label>
|
||||||
|
<div class="versions-list" id="versionsList">
|
||||||
|
${this.versions.map(v => `
|
||||||
|
<div class="version-item ${v.is_active ? "active" : ""}">
|
||||||
|
<span class="ver">v${v.version} ${v.is_active ? "(activa)" : ""}</span>
|
||||||
|
<span class="date">${this.formatDate(v.created_at)}</span>
|
||||||
|
${!v.is_active ? `<button class="small secondary" data-version="${v.version}">Restaurar</button>` : ""}
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="saveBtn">Guardar Cambios</button>
|
||||||
|
<button id="testBtn" class="secondary">Probar Prompt</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-section" id="testSection" style="display:none;">
|
||||||
|
<h4>Probar Prompt</h4>
|
||||||
|
<div class="field">
|
||||||
|
<label>Mensaje de prueba</label>
|
||||||
|
<input type="text" id="testMessage" placeholder="Ej: Hola, quiero 2kg de asado" />
|
||||||
|
</div>
|
||||||
|
<button id="runTestBtn" style="margin-top:8px;">Ejecutar Prueba</button>
|
||||||
|
<div id="testResultContainer"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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, ">").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 = `<div class="test-result">Ejecutando prueba...</div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="test-result success">${this.escapeHtml(parsed)}</div>
|
||||||
|
<div class="test-meta">
|
||||||
|
Modelo: ${result.model} | Latencia: ${result.latency_ms}ms |
|
||||||
|
Tokens: ${result.usage?.total_tokens || "?"}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div class="test-result error">Error: ${result.error || "Unknown"}</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error testing prompt:", e);
|
||||||
|
container.innerHTML = `<div class="test-result error">Error: ${e.message || e}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("prompts-crud", PromptsCrud);
|
||||||
371
public/components/settings-crud.js
Normal file
371
public/components/settings-crud.js
Normal file
@@ -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 = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; height:100%; padding:16px; overflow:auto; }
|
||||||
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
|
.container { max-width:800px; margin:0 auto; }
|
||||||
|
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:20px; margin-bottom:16px; }
|
||||||
|
.panel-title { font-size:16px; font-weight:700; color:#e7eef7; margin-bottom:16px; display:flex; align-items:center; gap:8px; }
|
||||||
|
.panel-title svg { width:20px; height:20px; fill:#1f6feb; }
|
||||||
|
|
||||||
|
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||||
|
.form-row.full { grid-template-columns:1fr; }
|
||||||
|
|
||||||
|
.field { }
|
||||||
|
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
.field-hint { font-size:11px; color:#6c7a89; margin-top:4px; }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
background:#0f1520; color:#e7eef7; border:1px solid #253245;
|
||||||
|
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||||
|
input:disabled { opacity:.6; cursor:not-allowed; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor:pointer; background:#1f6feb; color:#fff; border:none;
|
||||||
|
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
|
||||||
|
}
|
||||||
|
button:hover { background:#1a5fd0; }
|
||||||
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
button.secondary { background:#253245; }
|
||||||
|
button.secondary:hover { background:#2d3e52; }
|
||||||
|
|
||||||
|
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:12px; }
|
||||||
|
.toggle {
|
||||||
|
position:relative; width:48px; height:26px;
|
||||||
|
background:#253245; border-radius:13px; cursor:pointer;
|
||||||
|
transition:background .2s;
|
||||||
|
}
|
||||||
|
.toggle.active { background:#1f6feb; }
|
||||||
|
.toggle::after {
|
||||||
|
content:''; position:absolute; top:3px; left:3px;
|
||||||
|
width:20px; height:20px; background:#fff; border-radius:50%;
|
||||||
|
transition:transform .2s;
|
||||||
|
}
|
||||||
|
.toggle.active::after { transform:translateX(22px); }
|
||||||
|
.toggle-label { font-size:14px; color:#e7eef7; }
|
||||||
|
|
||||||
|
.days-selector { display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
|
.day-btn {
|
||||||
|
padding:8px 12px; border-radius:6px; font-size:12px; font-weight:600;
|
||||||
|
background:#253245; color:#8aa0b5; border:none; cursor:pointer;
|
||||||
|
transition:all .15s;
|
||||||
|
}
|
||||||
|
.day-btn:hover { background:#2d3e52; color:#e7eef7; }
|
||||||
|
.day-btn.selected { background:#1f6feb; color:#fff; }
|
||||||
|
|
||||||
|
.hours-row { display:flex; align-items:center; gap:12px; margin-top:12px; }
|
||||||
|
.hours-row input { width:90px; text-align:center; font-family:monospace; font-size:15px; letter-spacing:1px; }
|
||||||
|
.hours-row input::placeholder { color:#6c7a89; }
|
||||||
|
.hours-row span { color:#8aa0b5; }
|
||||||
|
.hours-row .hour-hint { font-size:11px; color:#6c7a89; margin-left:8px; }
|
||||||
|
|
||||||
|
.actions { display:flex; gap:12px; margin-top:24px; }
|
||||||
|
.loading { text-align:center; padding:60px; color:#8aa0b5; }
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
background:#2ecc7130; border:1px solid #2ecc71;
|
||||||
|
color:#2ecc71; padding:12px 16px; border-radius:8px;
|
||||||
|
margin-bottom:16px; font-size:14px;
|
||||||
|
}
|
||||||
|
.error-msg {
|
||||||
|
background:#e74c3c30; border:1px solid #e74c3c;
|
||||||
|
color:#e74c3c; padding:12px 16px; border-radius:8px;
|
||||||
|
margin-bottom:16px; font-size:14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div id="messages"></div>
|
||||||
|
<div id="content">
|
||||||
|
<div class="loading">Cargando configuración...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="loading">Cargando configuración...</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings) {
|
||||||
|
content.innerHTML = `<div class="loading">No se pudo cargar la configuración</div>`;
|
||||||
|
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 = `
|
||||||
|
<!-- Info del Negocio -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||||
|
Información del Negocio
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Nombre del negocio</label>
|
||||||
|
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
|
||||||
|
<div class="field-hint">Se usa en los mensajes del bot</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nombre del bot</label>
|
||||||
|
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
|
||||||
|
<div class="field-hint">El asistente virtual</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Dirección</label>
|
||||||
|
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Ej: Av. Corrientes 1234, CABA" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Teléfono</label>
|
||||||
|
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="Ej: +54 11 1234-5678" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delivery -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
|
||||||
|
Delivery (Envío a domicilio)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle ${s.delivery_enabled ? "active" : ""}" id="deliveryToggle"></div>
|
||||||
|
<span class="toggle-label">Delivery habilitado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="deliveryOptions" style="${s.delivery_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Días disponibles</label>
|
||||||
|
<div class="days-selector" id="deliveryDays">
|
||||||
|
${DAYS.map(d => `
|
||||||
|
<button type="button" class="day-btn ${deliveryDays.includes(d.id) ? "selected" : ""}" data-day="${d.id}">${d.label}</button>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hours-row">
|
||||||
|
<span>Horario:</span>
|
||||||
|
<input type="text" id="deliveryStart" value="${s.delivery_hours_start || "09:00"}" placeholder="09:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||||
|
<span>a</span>
|
||||||
|
<input type="text" id="deliveryEnd" value="${s.delivery_hours_end || "18:00"}" placeholder="18:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||||
|
<span class="hour-hint">(formato 24hs)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-top:12px;">
|
||||||
|
<label>Pedido mínimo ($)</label>
|
||||||
|
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retiro en tienda -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
|
||||||
|
Retiro en Tienda
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
|
||||||
|
<span class="toggle-label">Retiro en tienda habilitado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pickupOptions" style="${s.pickup_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Días disponibles</label>
|
||||||
|
<div class="days-selector" id="pickupDays">
|
||||||
|
${DAYS.map(d => `
|
||||||
|
<button type="button" class="day-btn ${pickupDays.includes(d.id) ? "selected" : ""}" data-day="${d.id}">${d.label}</button>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hours-row">
|
||||||
|
<span>Horario:</span>
|
||||||
|
<input type="text" id="pickupStart" value="${s.pickup_hours_start || "08:00"}" placeholder="08:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||||
|
<span>a</span>
|
||||||
|
<input type="text" id="pickupEnd" value="${s.pickup_hours_end || "20:00"}" placeholder="20:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||||
|
<span class="hour-hint">(formato 24hs)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
|
||||||
|
<button id="resetBtn" class="secondary">Restaurar</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(msg) {
|
||||||
|
const messages = this.shadowRoot.getElementById("messages");
|
||||||
|
messages.innerHTML = `<div class="success-msg">${msg}</div>`;
|
||||||
|
setTimeout(() => { messages.innerHTML = ""; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(msg) {
|
||||||
|
const messages = this.shadowRoot.getElementById("messages");
|
||||||
|
messages.innerHTML = `<div class="error-msg">${msg}</div>`;
|
||||||
|
setTimeout(() => { messages.innerHTML = ""; }, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("settings-crud", SettingsCrud);
|
||||||
409
public/components/takeovers-crud.js
Normal file
409
public/components/takeovers-crud.js
Normal file
@@ -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 = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
|
.container { display:grid; grid-template-columns:350px 1fr; gap:16px; height:100%; }
|
||||||
|
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
|
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
||||||
|
.panel-title .badge { background:#e74c3c; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
|
||||||
|
|
||||||
|
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||||
|
textarea { resize:vertical; min-height:100px; }
|
||||||
|
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
|
button:hover { background:#1a5fd0; }
|
||||||
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
button.secondary { background:#253245; }
|
||||||
|
button.secondary:hover { background:#2d3e52; }
|
||||||
|
button.danger { background:#e74c3c; }
|
||||||
|
button.danger:hover { background:#c0392b; }
|
||||||
|
|
||||||
|
.list { flex:1; overflow-y:auto; }
|
||||||
|
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
|
.item:hover { border-color:#1f6feb; }
|
||||||
|
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||||
|
.item-query { font-weight:600; color:#f39c12; margin-bottom:4px; font-size:14px; }
|
||||||
|
.item-reason { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
|
||||||
|
.item-time { font-size:11px; color:#6c7a89; }
|
||||||
|
.item-chat { font-size:11px; color:#1f6feb; }
|
||||||
|
|
||||||
|
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||||
|
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||||
|
.field { }
|
||||||
|
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
|
||||||
|
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||||
|
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||||
|
|
||||||
|
.conversation-history { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
|
||||||
|
.msg { margin-bottom:8px; padding:8px; border-radius:6px; font-size:12px; }
|
||||||
|
.msg.user { background:#1a2a3a; border-left:3px solid #1f6feb; }
|
||||||
|
.msg.assistant { background:#1a2535; border-left:3px solid #2ecc71; }
|
||||||
|
.msg-role { font-size:10px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; }
|
||||||
|
.msg-content { color:#e7eef7; white-space:pre-wrap; }
|
||||||
|
|
||||||
|
.query-highlight { background:#f39c1230; border:1px solid #f39c12; border-radius:8px; padding:12px; margin-bottom:16px; }
|
||||||
|
.query-highlight label { color:#f39c12; }
|
||||||
|
.query-highlight .query { font-size:16px; font-weight:600; color:#f39c12; margin-top:4px; }
|
||||||
|
|
||||||
|
.alias-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:12px; }
|
||||||
|
.alias-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
||||||
|
.checkbox-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
|
||||||
|
.checkbox-row input[type="checkbox"] { width:auto; }
|
||||||
|
.checkbox-row label { font-size:13px; color:#e7eef7; text-transform:none; }
|
||||||
|
|
||||||
|
.product-selector { position:relative; }
|
||||||
|
.product-dropdown {
|
||||||
|
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||||
|
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
||||||
|
max-height:200px; overflow-y:auto; display:none;
|
||||||
|
}
|
||||||
|
.product-dropdown.open { display:block; }
|
||||||
|
.product-option {
|
||||||
|
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
||||||
|
display:flex; justify-content:space-between; align-items:center;
|
||||||
|
}
|
||||||
|
.product-option:hover { background:#1a2535; }
|
||||||
|
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||||
|
|
||||||
|
.no-pending { text-align:center; padding:60px 20px; color:#2ecc71; }
|
||||||
|
.no-pending svg { width:48px; height:48px; fill:#2ecc71; margin-bottom:16px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
Takeovers Pendientes
|
||||||
|
<span class="badge" id="pendingBadge" style="display:none;">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="list">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">Responder</div>
|
||||||
|
<div class="form" id="form">
|
||||||
|
<div class="form-empty">Selecciona un takeover para responder</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="loading">Cargando...</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.items.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="no-pending">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
|
||||||
|
<div>No hay takeovers pendientes</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="item-query">"${item.pending_query}"</div>
|
||||||
|
<div class="item-reason">${this.getReasonLabel(item.reason)}</div>
|
||||||
|
<div class="item-chat">Chat: ${item.chat_id}</div>
|
||||||
|
<div class="item-time">${this.formatTime(item.created_at)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<div class="form-empty">Selecciona un takeover para responder</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = this.selected.conversation_history || this.selected.recent_messages || [];
|
||||||
|
|
||||||
|
form.innerHTML = `
|
||||||
|
<div class="query-highlight">
|
||||||
|
<label>Consulta del cliente</label>
|
||||||
|
<div class="query">"${this.selected.pending_query}"</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${history.length > 0 ? `
|
||||||
|
<div class="field">
|
||||||
|
<label>Historial de conversacion</label>
|
||||||
|
<div class="conversation-history">
|
||||||
|
${history.slice(-10).map(m => `
|
||||||
|
<div class="msg ${m.role}">
|
||||||
|
<div class="msg-role">${m.role === "user" ? "Cliente" : "Bot"}</div>
|
||||||
|
<div class="msg-content">${this.escapeHtml((m.content || "").slice(0, 300))}</div>
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
<div class="field" style="flex:1;">
|
||||||
|
<label>Tu respuesta (se enviara como el bot)</label>
|
||||||
|
<textarea id="responseInput" placeholder="Ej: Disculpa, no tenemos ese producto. Pero tenemos..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alias-section">
|
||||||
|
<h4>Agregar Alias (opcional)</h4>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="addAliasCheck" />
|
||||||
|
<label for="addAliasCheck">Agregar "${this.selected.pending_query}" como alias de un producto</label>
|
||||||
|
</div>
|
||||||
|
<div id="aliasProductSection" style="display:none;">
|
||||||
|
<div class="field">
|
||||||
|
<label>Seleccionar producto</label>
|
||||||
|
<div class="product-selector" id="productSelector">
|
||||||
|
<input type="text" id="productSearch" placeholder="Buscar producto..." />
|
||||||
|
<div class="product-dropdown" id="productDropdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="respondBtn">Enviar Respuesta</button>
|
||||||
|
<button id="cancelBtn" class="danger">Cancelar Takeover</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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, ">").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 => `
|
||||||
|
<div class="product-option" data-id="${p.woo_product_id}">
|
||||||
|
<span>${p.name}</span>
|
||||||
|
<span class="price">$${p.price || 0}</span>
|
||||||
|
</div>
|
||||||
|
`).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);
|
||||||
@@ -219,4 +219,84 @@ export const api = {
|
|||||||
body: JSON.stringify({ woo_order_id, amount }),
|
body: JSON.stringify({ woo_order_id, amount }),
|
||||||
}).then(r => r.json());
|
}).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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const ROUTES = [
|
|||||||
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
|
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
|
||||||
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
|
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
|
||||||
{ pattern: /^\/test$/, view: "test", params: [] },
|
{ 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)
|
// Mapeo de vistas a rutas base (para navegación sin parámetros)
|
||||||
@@ -29,6 +32,9 @@ const VIEW_TO_PATH = {
|
|||||||
quantities: "/cantidades",
|
quantities: "/cantidades",
|
||||||
orders: "/pedidos",
|
orders: "/pedidos",
|
||||||
test: "/test",
|
test: "/test",
|
||||||
|
prompts: "/config-prompts",
|
||||||
|
takeovers: "/atencion-humana",
|
||||||
|
settings: "/configuracion",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function createApp({ tenantId }) {
|
|||||||
// SPA catch-all - sirve index.html para todas las rutas del frontend
|
// SPA catch-all - sirve index.html para todas las rutas del frontend
|
||||||
const spaRoutes = [
|
const spaRoutes = [
|
||||||
'/chat', '/conversaciones', '/usuarios', '/productos',
|
'/chat', '/conversaciones', '/usuarios', '/productos',
|
||||||
'/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test'
|
'/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test',
|
||||||
|
'/config-prompts', '/atencion-humana', '/configuracion'
|
||||||
];
|
];
|
||||||
app.get(spaRoutes, (req, res) => {
|
app.get(spaRoutes, (req, res) => {
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
|
|||||||
158
src/modules/0-ui/controllers/prompts.js
Normal file
158
src/modules/0-ui/controllers/prompts.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
36
src/modules/0-ui/controllers/settings.js
Normal file
36
src/modules/0-ui/controllers/settings.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
126
src/modules/0-ui/controllers/takeovers.js
Normal file
126
src/modules/0-ui/controllers/takeovers.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
183
src/modules/0-ui/db/promptsRepo.js
Normal file
183
src/modules/0-ui/db/promptsRepo.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
174
src/modules/0-ui/db/settingsRepo.js
Normal file
174
src/modules/0-ui/db/settingsRepo.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
182
src/modules/0-ui/db/takeoverRepo.js
Normal file
182
src/modules/0-ui/db/takeoverRepo.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
258
src/modules/0-ui/handlers/prompts.js
Normal file
258
src/modules/0-ui/handlers/prompts.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/modules/0-ui/handlers/settings.js
Normal file
114
src/modules/0-ui/handlers/settings.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
246
src/modules/0-ui/handlers/takeovers.js
Normal file
246
src/modules/0-ui/handlers/takeovers.js
Normal file
@@ -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";
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts,
|
|||||||
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
||||||
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
||||||
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.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 { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||||
import { makeListRecentOrders, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.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.get("/quantities/:wooProductId", makeGetProductQtyRules(getTenantId));
|
||||||
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(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.get("/users", makeListUsers(getTenantId));
|
||||||
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));
|
router.delete("/users/:chat_id", makeDeleteUser(getTenantId));
|
||||||
|
|
||||||
|
|||||||
@@ -232,22 +232,36 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
for (const act of actions) {
|
for (const act of actions) {
|
||||||
try {
|
try {
|
||||||
if (act.type === "create_order") {
|
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({
|
const order = await createOrder({
|
||||||
tenantId,
|
tenantId,
|
||||||
wooCustomerId: externalCustomerId,
|
wooCustomerId: externalCustomerId,
|
||||||
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
basket: basketToUse,
|
||||||
address: reducedContext?.delivery_address || reducedContext?.address || null,
|
address: addressWithPhone,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
});
|
});
|
||||||
actionPatch.woo_order_id = order?.id || null;
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
actionPatch.order_total = calcOrderTotal(order);
|
actionPatch.order_total = calcOrderTotal(order);
|
||||||
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
|
newTools.push({ type: "create_order", ok: true, order_id: order?.id || null });
|
||||||
} else if (act.type === "update_order") {
|
} 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({
|
const order = await updateOrder({
|
||||||
tenantId,
|
tenantId,
|
||||||
wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null,
|
wooOrderId: reducedContext?.woo_order_id || prev?.last_order_id || null,
|
||||||
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
basket: reducedContext?.order_basket || plan?.basket_resolved || { items: [] },
|
||||||
address: reducedContext?.delivery_address || reducedContext?.address || null,
|
address: addressWithPhoneUpd,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
});
|
});
|
||||||
actionPatch.woo_order_id = order?.id || null;
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const ConversationState = Object.freeze({
|
|||||||
SHIPPING: "SHIPPING",
|
SHIPPING: "SHIPPING",
|
||||||
PAYMENT: "PAYMENT",
|
PAYMENT: "PAYMENT",
|
||||||
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
|
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
|
||||||
|
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||||
@@ -33,23 +34,46 @@ export const INTENTS_BY_STATE = Object.freeze({
|
|||||||
[ConversationState.WAITING_WEBHOOKS]: [
|
[ConversationState.WAITING_WEBHOOKS]: [
|
||||||
"add_to_cart", "view_cart", "other"
|
"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).
|
* 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) {
|
if (state === ConversationState.CART || state === ConversationState.IDLE) {
|
||||||
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
|
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;
|
const intent = nlu?.intent;
|
||||||
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
|
// 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)) {
|
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;
|
// Si hay menciones de producto en entities (con contenido real)
|
||||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,26 +191,36 @@ const ALLOWED = Object.freeze({
|
|||||||
[ConversationState.IDLE]: [
|
[ConversationState.IDLE]: [
|
||||||
ConversationState.IDLE,
|
ConversationState.IDLE,
|
||||||
ConversationState.CART,
|
ConversationState.CART,
|
||||||
|
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
|
||||||
],
|
],
|
||||||
[ConversationState.CART]: [
|
[ConversationState.CART]: [
|
||||||
ConversationState.CART,
|
ConversationState.CART,
|
||||||
ConversationState.SHIPPING,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.IDLE, // Si vacía el carrito
|
ConversationState.IDLE, // Si vacía el carrito
|
||||||
|
ConversationState.AWAITING_HUMAN, // Producto no encontrado
|
||||||
],
|
],
|
||||||
[ConversationState.SHIPPING]: [
|
[ConversationState.SHIPPING]: [
|
||||||
ConversationState.SHIPPING,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.PAYMENT,
|
ConversationState.PAYMENT,
|
||||||
ConversationState.CART, // Volver a agregar productos
|
ConversationState.CART, // Volver a agregar productos
|
||||||
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.PAYMENT]: [
|
[ConversationState.PAYMENT]: [
|
||||||
ConversationState.PAYMENT,
|
ConversationState.PAYMENT,
|
||||||
ConversationState.WAITING_WEBHOOKS,
|
ConversationState.WAITING_WEBHOOKS,
|
||||||
ConversationState.CART, // Volver a agregar productos
|
ConversationState.CART, // Volver a agregar productos
|
||||||
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.WAITING_WEBHOOKS]: [
|
[ConversationState.WAITING_WEBHOOKS]: [
|
||||||
ConversationState.WAITING_WEBHOOKS,
|
ConversationState.WAITING_WEBHOOKS,
|
||||||
ConversationState.IDLE, // Pago completado
|
ConversationState.IDLE, // Pago completado
|
||||||
ConversationState.CART, // Agregar más productos
|
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ó
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
73
src/modules/3-turn-engine/nlu/defaults/browse.txt
Normal file
73
src/modules/3-turn-engine/nlu/defaults/browse.txt
Normal file
@@ -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
|
||||||
|
}
|
||||||
23
src/modules/3-turn-engine/nlu/defaults/greeting.txt
Normal file
23
src/modules/3-turn-engine/nlu/defaults/greeting.txt
Normal file
@@ -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"
|
||||||
|
}
|
||||||
98
src/modules/3-turn-engine/nlu/defaults/orders.txt
Normal file
98
src/modules/3-turn-engine/nlu/defaults/orders.txt
Normal file
@@ -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}, ...]
|
||||||
|
}
|
||||||
60
src/modules/3-turn-engine/nlu/defaults/payment.txt
Normal file
60
src/modules/3-turn-engine/nlu/defaults/payment.txt
Normal file
@@ -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
|
||||||
|
}
|
||||||
33
src/modules/3-turn-engine/nlu/defaults/router.txt
Normal file
33
src/modules/3-turn-engine/nlu/defaults/router.txt
Normal file
@@ -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]
|
||||||
64
src/modules/3-turn-engine/nlu/defaults/shipping.txt
Normal file
64
src/modules/3-turn-engine/nlu/defaults/shipping.txt
Normal file
@@ -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
|
||||||
|
}
|
||||||
164
src/modules/3-turn-engine/nlu/humanFallback.js
Normal file
164
src/modules/3-turn-engine/nlu/humanFallback.js
Normal file
@@ -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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
189
src/modules/3-turn-engine/nlu/index.js
Normal file
189
src/modules/3-turn-engine/nlu/index.js
Normal file
@@ -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;
|
||||||
204
src/modules/3-turn-engine/nlu/promptLoader.js
Normal file
204
src/modules/3-turn-engine/nlu/promptLoader.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
175
src/modules/3-turn-engine/nlu/router.js
Normal file
175
src/modules/3-turn-engine/nlu/router.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
283
src/modules/3-turn-engine/nlu/schemas.js
Normal file
283
src/modules/3-turn-engine/nlu/schemas.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
170
src/modules/3-turn-engine/nlu/specialists/browse.js
Normal file
170
src/modules/3-turn-engine/nlu/specialists/browse.js
Normal file
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
100
src/modules/3-turn-engine/nlu/specialists/greeting.js
Normal file
100
src/modules/3-turn-engine/nlu/specialists/greeting.js
Normal file
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
162
src/modules/3-turn-engine/nlu/specialists/orders.js
Normal file
162
src/modules/3-turn-engine/nlu/specialists/orders.js
Normal file
@@ -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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
135
src/modules/3-turn-engine/nlu/specialists/payment.js
Normal file
135
src/modules/3-turn-engine/nlu/specialists/payment.js
Normal file
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
157
src/modules/3-turn-engine/nlu/specialists/shipping.js
Normal file
157
src/modules/3-turn-engine/nlu/specialists/shipping.js
Normal file
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
formatOptionsForDisplay,
|
formatOptionsForDisplay,
|
||||||
} from "./orderModel.js";
|
} from "./orderModel.js";
|
||||||
import { handleRecommend } from "./recommendations.js";
|
import { handleRecommend } from "./recommendations.js";
|
||||||
|
import { getProductQtyRules } from "../0-ui/db/repo.js";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Utilidades
|
// Utilidades
|
||||||
@@ -356,6 +357,62 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (nextPending.status === PendingStatus.NEEDS_QUANTITY) {
|
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");
|
const unitQuestion = unitAskFor(nextPending.selected_unit || "kg");
|
||||||
return {
|
return {
|
||||||
plan: {
|
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
|
// No entendió cantidad
|
||||||
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg");
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
*
|
*
|
||||||
* Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
|
* Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
|
||||||
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
* 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 { llmNluV3 } from "./openai.js";
|
||||||
|
import { llmNluModular } from "./nlu/index.js";
|
||||||
import { ConversationState, shouldReturnToCart, safeNextState } from "./fsm.js";
|
import { ConversationState, shouldReturnToCart, safeNextState } from "./fsm.js";
|
||||||
import { migrateOldContext, createEmptyOrder } from "./orderModel.js";
|
import { migrateOldContext, createEmptyOrder } from "./orderModel.js";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +18,10 @@ import {
|
|||||||
handlePaymentState,
|
handlePaymentState,
|
||||||
handleWaitingState,
|
handleWaitingState,
|
||||||
} from "./stateHandlers.js";
|
} 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
|
* Genera un resumen corto del historial para el NLU
|
||||||
@@ -58,7 +65,7 @@ export async function runTurnV3({
|
|||||||
const normalizedState = normalizeState(prev_state);
|
const normalizedState = normalizeState(prev_state);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// NLU
|
// NLU (con feature flag para sistema modular)
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const nluInput = {
|
const nluInput = {
|
||||||
@@ -73,8 +80,37 @@ export async function runTurnV3({
|
|||||||
locale: "es-AR",
|
locale: "es-AR",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nlu, raw_text, model, usage, validation } = await llmNluV3({ input: nluInput });
|
let nluResult;
|
||||||
audit.nlu = { raw_text, model, usage, validation, parsed: nlu };
|
|
||||||
|
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
|
// Dispatcher por estado
|
||||||
@@ -90,7 +126,8 @@ export async function runTurnV3({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Regla universal: si quiere agregar productos, volver a CART
|
// 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 });
|
const result = await handleCartState({ ...handlerParams, fromIdle: false });
|
||||||
return formatResult(result, prev_context);
|
return formatResult(result, prev_context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ async function buildLineItems({ tenantId, basket }) {
|
|||||||
const lineItems = [];
|
const lineItems = [];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const productId = Number(it.product_id);
|
const productId = Number(it.product_id);
|
||||||
const unit = String(it.unit);
|
const unit = String(it.unit).toLowerCase();
|
||||||
const qty = Number(it.quantity);
|
const qty = Number(it.quantity);
|
||||||
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
|
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
|
||||||
const pricePerKg = await getWooProductPrice({ tenantId, productId });
|
const pricePerKg = await getWooProductPrice({ tenantId, productId });
|
||||||
@@ -144,8 +144,10 @@ async function buildLineItems({ tenantId, basket }) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carne por gramos (enteros): quantity=1 y total calculado en base a ARS/kg
|
// Carne por peso: convertir a gramos
|
||||||
const grams = Math.round(qty);
|
// 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 kilos = grams / 1000;
|
||||||
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
|
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
@@ -165,6 +167,13 @@ async function buildLineItems({ tenantId, basket }) {
|
|||||||
|
|
||||||
function mapAddress(address) {
|
function mapAddress(address) {
|
||||||
if (!address || typeof address !== "object") return null;
|
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 {
|
return {
|
||||||
first_name: address.first_name || "",
|
first_name: address.first_name || "",
|
||||||
last_name: address.last_name || "",
|
last_name: address.last_name || "",
|
||||||
@@ -175,7 +184,7 @@ function mapAddress(address) {
|
|||||||
postcode: address.postcode || "",
|
postcode: address.postcode || "",
|
||||||
country: address.country || "AR",
|
country: address.country || "AR",
|
||||||
phone: address.phone || "",
|
phone: address.phone || "",
|
||||||
email: address.email || "",
|
email,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user