diff --git a/src/main/java/com/p2ps/ai/service/AiOrchestrationService.java b/src/main/java/com/p2ps/ai/service/AiOrchestrationService.java index cda42e7..16a5278 100644 --- a/src/main/java/com/p2ps/ai/service/AiOrchestrationService.java +++ b/src/main/java/com/p2ps/ai/service/AiOrchestrationService.java @@ -11,6 +11,7 @@ import com.p2ps.catalog.service.ProductResolutionService; import com.p2ps.exception.AiProcessingException; import com.p2ps.util.ProductStringUtils; +import com.p2ps.util.QuantityParser; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -159,6 +160,8 @@ private String abbreviate(String raw) { private void normalizeDetectedProducts(AiGenerationResponse response, Users user) { // Extragem email-ul în siguranță pentru a preveni NullPointerException String email = (user != null) ? user.getEmail() : null; + String listType = response.getListType(); + boolean recipeList = listType != null && "RECIPE".equalsIgnoreCase(listType.trim()); for (ParsedItemResponse item : response.getItems()) { if (item != null) { @@ -166,12 +169,13 @@ private void normalizeDetectedProducts(AiGenerationResponse response, Users user if (keyword != null) { // Java face căutarea INSTANT, direct pe backend! productResolutionService.resolveForUser(keyword, email) - .ifPresent(match -> applyResolvedProduct(item, match)); + .ifPresent(match -> applyResolvedProduct(item, match, recipeList)); } } } } - private void applyResolvedProduct(ParsedItemResponse item, ProductResolutionService.ResolvedProduct match) { + + private void applyResolvedProduct(ParsedItemResponse item, ProductResolutionService.ResolvedProduct match, boolean recipeList) { ProductCatalog catalogProduct = match.catalogProduct(); item.setGenericName(ProductStringUtils.firstNonBlank( match.matchedName(), @@ -184,6 +188,7 @@ private void applyResolvedProduct(ParsedItemResponse item, ProductResolutionServ item.setSpecificName(ProductStringUtils.firstNonBlank(catalogProduct.getSpecificName(), item.getSpecificName())); item.setBrand(ProductStringUtils.firstNonBlank(catalogProduct.getBrand(), item.getBrand(), match.brand())); item.setCategory(ProductStringUtils.firstNonBlank(catalogProduct.getCategory(), item.getCategory(), match.category())); + applyRecipeQuantityNormalization(item, catalogProduct, recipeList); return; } @@ -191,4 +196,34 @@ private void applyResolvedProduct(ParsedItemResponse item, ProductResolutionServ item.setCategory(ProductStringUtils.firstNonBlank(item.getCategory(), match.category())); } -} \ No newline at end of file + private void applyRecipeQuantityNormalization(ParsedItemResponse item, ProductCatalog catalogProduct, boolean recipeList) { + if (!recipeList || catalogProduct == null) { + return; + } + + String defaultQuantity = catalogProduct.getDefaultQuantity(); + String itemQuantity = item.getQuantity(); + String itemUnit = item.getUnit(); + if (defaultQuantity == null || defaultQuantity.isBlank() || itemQuantity == null || itemQuantity.isBlank() + || itemUnit == null || itemUnit.isBlank()) { + return; + } + + try { + String converted = QuantityParser.convertToUnit(itemQuantity.trim() + " " + itemUnit.trim(), defaultQuantity.trim()); + QuantityParser.ParsedQuantity parsedConverted = QuantityParser.parse(converted); + item.setQuantity(formatQuantityValue(parsedConverted.value())); + item.setUnit(parsedConverted.unit().symbol()); + } catch (RuntimeException _) { + // Keep the AI quantity when parsing or conversion fails. + } + } + + private String formatQuantityValue(double value) { + if (value == (long) value) { + return Long.toString((long) value); + } + return Double.toString(value); + } + +} diff --git a/src/main/java/com/p2ps/ai/service/AiService.java b/src/main/java/com/p2ps/ai/service/AiService.java index a0c0895..893b9df 100644 --- a/src/main/java/com/p2ps/ai/service/AiService.java +++ b/src/main/java/com/p2ps/ai/service/AiService.java @@ -47,6 +47,7 @@ EXTRACTION RULE (CRITICAL): Extract EXACTLY what the user said. DO NOT invent or RULE 2 (LOCATION AWARENESS): If user coordinates are provided, use the 'find_optimal_store' tool to recommend the best place to shop. RULE 3 (TIERED CATEGORIZATION): Classify the list as 'RECIPE', 'FREQUENT', or 'CART'. RECIPE LOGIC: If the user describes a dish, dessert, meal, or recipe idea (e.g., negresa, clatite, ciorba, pasta, cake), classify it as 'RECIPE' even if the word 'recipe' is not used. + RECIPE QUANTITY RULE (CRITICAL): For RECIPE outputs, estimate a realistic required quantity for each ingredient and always return both quantity and unit when the ingredient is measurable. Return the amount needed by the recipe itself, not package size or store packaging. If the exact amount is unclear, return your best conservative estimate. CRITICAL CATEGORY RULE: The 'category' field MUST be chosen EXACTLY from this strict list: [Fructe și Legume, Lactate și Ouă, Carne, Băcănie, Dulciuri, Curățenie, Altele]. DO NOT invent categories! Format: {"listType": "string", "suggestedStore": "string or null", "items": [{"genericName": "string", "specificName": "string or null", "brand": "string or null", "quantity": number or null, "unit": "string or null", "catalogId": "string or null", "category": "string", "price": number or null}]}. """; diff --git a/src/main/java/com/p2ps/catalog/service/GlobalCatalogPopulationJob.java b/src/main/java/com/p2ps/catalog/service/GlobalCatalogPopulationJob.java index 368f2d1..943020c 100644 --- a/src/main/java/com/p2ps/catalog/service/GlobalCatalogPopulationJob.java +++ b/src/main/java/com/p2ps/catalog/service/GlobalCatalogPopulationJob.java @@ -11,14 +11,14 @@ public class GlobalCatalogPopulationJob { private final GlobalCatalogPopulationService globalCatalogPopulationService; - @Value("${catalog.population.min-distinct-users:3}") + @Value("${catalog.population.min-distinct-users:1}") private int minDistinctUsers; public GlobalCatalogPopulationJob(GlobalCatalogPopulationService globalCatalogPopulationService) { this.globalCatalogPopulationService = globalCatalogPopulationService; } - @Scheduled(fixedRate = 100*60000) + @Scheduled(fixedRate = 60*3*60000) public void populateGlobalCatalog() { int processedCount = globalCatalogPopulationService.populateFromPopularUnknownProducts(minDistinctUsers); log.info("[GLOBAL_CATALOG_POPULATION] Processed {} popular unknown product groups", processedCount); diff --git a/src/main/java/com/p2ps/catalog/service/ProductResolutionService.java b/src/main/java/com/p2ps/catalog/service/ProductResolutionService.java index 55fce73..c1cfc61 100644 --- a/src/main/java/com/p2ps/catalog/service/ProductResolutionService.java +++ b/src/main/java/com/p2ps/catalog/service/ProductResolutionService.java @@ -73,6 +73,7 @@ private Optional resolveFromUserHistory(String keyword, String catalogProduct.setId(match.getCatalogId()); catalogProduct.setGenericName(match.getCatalogGenericName()); catalogProduct.setSpecificName(match.getCatalogSpecificName()); + catalogProduct.setDefaultQuantity(match.getCatalogDefaultQuantity()); catalogProduct.setBrand(match.getBrand()); catalogProduct.setCategory(match.getCategory()); } @@ -106,4 +107,4 @@ private String firstNonBlank(String... values) { } return null; } -} \ No newline at end of file +} diff --git a/src/main/java/com/p2ps/util/QuantityParser.java b/src/main/java/com/p2ps/util/QuantityParser.java index e412cf9..6657594 100644 --- a/src/main/java/com/p2ps/util/QuantityParser.java +++ b/src/main/java/com/p2ps/util/QuantityParser.java @@ -32,6 +32,10 @@ public enum Unit { this.family = family; } + public String symbol() { + return symbol; + } + public static Unit fromString(String str) { if (str == null || str.isBlank()) return PCS; String normalized = normalizeUnit(str); @@ -95,6 +99,19 @@ public static String addQuantities(String q1, String q2) { return formatToOptimalUnit(totalBaseValue, parsed1.unit().family); } + public static String convertToUnit(String sourceQuantity, String targetQuantityTemplate) { + ParsedQuantity source = parse(sourceQuantity); + ParsedQuantity target = parse(targetQuantityTemplate); + + if (!source.unit().family.equals(target.unit().family)) { + throw new ListValidationException("Cannot convert quantity between different unit families."); + } + + double sourceBaseValue = source.value() * source.unit().baseMultiplier; + double convertedValue = sourceBaseValue / target.unit().baseMultiplier; + return formatNumber(convertedValue) + " " + target.unit().symbol; + } + private static String formatToOptimalUnit(double baseValue, String family) { if (WEIGHT.equals(family)) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d1ffcfa..bbd389e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -51,7 +51,7 @@ catalog.store-price.retention-zone=${CATALOG_STORE_PRICE_RETENTION_ZONE:UTC} catalog.store-price.retention-days=${CATALOG_STORE_PRICE_RETENTION_DAYS:30} catalog.population.cron=${CATALOG_POPULATION_CRON:0 0 3 * * *} catalog.population.zone=${CATALOG_POPULATION_ZONE:UTC} -catalog.population.min-distinct-users=${CATALOG_POPULATION_MIN_DISTINCT_USERS:3} +catalog.population.min-distinct-users=${CATALOG_POPULATION_MIN_DISTINCT_USERS:1} # Telemetry deduplication telemetry.dedup.window-seconds=60 diff --git a/src/test/java/com/p2ps/ai/service/AiOrchestrationServiceTest.java b/src/test/java/com/p2ps/ai/service/AiOrchestrationServiceTest.java index 5a22b50..3b2508d 100644 --- a/src/test/java/com/p2ps/ai/service/AiOrchestrationServiceTest.java +++ b/src/test/java/com/p2ps/ai/service/AiOrchestrationServiceTest.java @@ -265,6 +265,7 @@ void generateShoppingItems_appliesUserHistoryOrCatalogNormalization() { catalogProduct.setSpecificName("Oua de gaina M"); catalogProduct.setBrand("Ferma"); catalogProduct.setCategory("Lactate"); + catalogProduct.setDefaultQuantity("10 buc"); Users user = new Users("user@test.com", "pass", "Test", "User"); user.setId(1); @@ -290,6 +291,128 @@ void generateShoppingItems_appliesUserHistoryOrCatalogNormalization() { assertThat(response.getItems().get(0).getCatalogId()).isEqualTo(catalogProduct.getId().toString()); } + @Test + void generateShoppingItems_recipe_convertsQuantityUsingUserHistoryCatalogDefaultQuantity() { + String validJson = """ + { + "listType": "RECIPE", + "items": [ + { + "genericName": "ou", + "quantity": "2", + "unit": "buc" + } + ] + } + """; + ProductCatalog catalogProduct = new ProductCatalog(); + catalogProduct.setId(UUID.randomUUID()); + catalogProduct.setGenericName("oua"); + catalogProduct.setSpecificName("Oua de gaina M"); + catalogProduct.setBrand("Ferma"); + catalogProduct.setCategory("Lactate"); + catalogProduct.setDefaultQuantity("10 buc"); + + Users user = new Users("user@test.com", "pass", "Test", "User"); + user.setId(1); + + when(aiService.extractFromMultimodal(null, "text", null, null, "user@test.com")).thenReturn(validJson); + when(userRepository.findByEmail("user@test.com")).thenReturn(Optional.of(user)); + when(productResolutionService.resolveForUser("ou", "user@test.com")) + .thenReturn(Optional.of(new ProductResolutionService.ResolvedProduct( + "oua", + "Ferma", + "Lactate", + catalogProduct, + "USER_HISTORY" + ))); + + AiGenerationResponse response = svc.generateShoppingItems(null, "text", null, null, "user@test.com"); + + assertThat(response.getItems()).hasSize(1); + assertThat(response.getItems().get(0).getQuantity()).isEqualTo("2"); + assertThat(response.getItems().get(0).getUnit()).isEqualTo("buc"); + assertThat(response.getItems().get(0).getCatalogId()).isEqualTo(catalogProduct.getId().toString()); + } + + @Test + void generateShoppingItems_recipe_convertsQuantityToCatalogDefaultUnitWithoutRoundingUp() { + String validJson = """ + { + "listType": "RECIPE", + "items": [ + { + "genericName": "lapte", + "quantity": "500", + "unit": "ml" + } + ] + } + """; + ProductCatalog catalogProduct = new ProductCatalog(); + catalogProduct.setId(UUID.randomUUID()); + catalogProduct.setGenericName("lapte"); + catalogProduct.setSpecificName("Lapte 1.5%"); + catalogProduct.setBrand("Zuzu"); + catalogProduct.setCategory("Lactate"); + catalogProduct.setDefaultQuantity("1 l"); + + when(aiService.extractFromMultimodal(null, "text", null, null, null)).thenReturn(validJson); + when(productResolutionService.resolveForUser("lapte", null)) + .thenReturn(Optional.of(new ProductResolutionService.ResolvedProduct( + "lapte", + "Zuzu", + "Lactate", + catalogProduct, + "GLOBAL_CATALOG" + ))); + + AiGenerationResponse response = svc.generateShoppingItems(null, "text", null, null, null); + + assertThat(response.getItems()).hasSize(1); + assertThat(response.getItems().get(0).getQuantity()).isEqualTo("0.5"); + assertThat(response.getItems().get(0).getUnit()).isEqualTo("l"); + } + + @Test + void generateShoppingItems_nonRecipe_keepsOriginalQuantityEvenWithCatalogDefaultUnit() { + String validJson = """ + { + "listType": "FREQUENT", + "items": [ + { + "genericName": "lapte", + "quantity": "500", + "unit": "ml" + } + ] + } + """; + ProductCatalog catalogProduct = new ProductCatalog(); + catalogProduct.setId(UUID.randomUUID()); + catalogProduct.setGenericName("lapte"); + catalogProduct.setSpecificName("Lapte 1.5%"); + catalogProduct.setBrand("Zuzu"); + catalogProduct.setCategory("Lactate"); + catalogProduct.setDefaultQuantity("1 l"); + + when(aiService.extractFromMultimodal(null, "text", null, null, null)).thenReturn(validJson); + when(productResolutionService.resolveForUser("lapte", null)) + .thenReturn(Optional.of(new ProductResolutionService.ResolvedProduct( + "lapte", + "Zuzu", + "Lactate", + catalogProduct, + "GLOBAL_CATALOG" + ))); + + AiGenerationResponse response = svc.generateShoppingItems(null, "text", null, null, null); + + assertThat(response.getItems()).hasSize(1); + assertThat(response.getItems().get(0).getQuantity()).isEqualTo("500"); + assertThat(response.getItems().get(0).getUnit()).isEqualTo("ml"); + } + @Test void generateShoppingItems_rethrowsNonRetryableAiProcessingException() { AiProcessingException forbidden = new AiProcessingException("Forbidden", null, HttpStatus.FORBIDDEN); @@ -332,4 +455,4 @@ void generateShoppingItems_withBlankUserEmail_skipsUserLookupAndAppliesNonCatalo assertThat(response.getItems().get(0).getCatalogId()).isNull(); verifyNoInteractions(userRepository); } -} \ No newline at end of file +} diff --git a/src/test/java/com/p2ps/util/QuantityParserTest.java b/src/test/java/com/p2ps/util/QuantityParserTest.java index bee4f8f..56bceec 100644 --- a/src/test/java/com/p2ps/util/QuantityParserTest.java +++ b/src/test/java/com/p2ps/util/QuantityParserTest.java @@ -87,4 +87,18 @@ void addQuantities_ThrowsException_OnMassiveOverflow() { .isInstanceOf(ListValidationException.class) .hasMessageContaining("too big to be processed"); } + + @Test + void convertToUnit_sameFamily_convertsToTargetUnitWithoutRoundingUp() { + assertThat(QuantityParser.convertToUnit("500 ml", "1 l")).isEqualTo("0.5 l"); + assertThat(QuantityParser.convertToUnit("1500 g", "1 kg")).isEqualTo("1.5 kg"); + assertThat(QuantityParser.convertToUnit("2 buc", "10 buc")).isEqualTo("2 buc"); + } + + @Test + void convertToUnit_differentFamilies_throwsException() { + assertThatThrownBy(() -> QuantityParser.convertToUnit("500 ml", "1 kg")) + .isInstanceOf(ListValidationException.class) + .hasMessageContaining("different unit families"); + } }