Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions src/main/java/com/p2ps/ai/service/AiOrchestrationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -159,19 +160,22 @@ 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) {
String keyword = ProductStringUtils.firstNonBlank(item.getGenericName(), item.getSpecificName(), item.getBrand());
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(),
Expand All @@ -184,11 +188,42 @@ 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;
}

item.setBrand(ProductStringUtils.firstNonBlank(item.getBrand(), match.brand()));
item.setCategory(ProductStringUtils.firstNonBlank(item.getCategory(), match.category()));
}

}
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);
}

}
1 change: 1 addition & 0 deletions src/main/java/com/p2ps/ai/service/AiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}]}.
""";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Comment thread
iuliaaa20 marked this conversation as resolved.
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ private Optional<ResolvedProduct> 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());
}
Expand Down Expand Up @@ -106,4 +107,4 @@ private String firstNonBlank(String... values) {
}
return null;
}
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/p2ps/util/QuantityParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 124 additions & 1 deletion src/test/java/com/p2ps/ai/service/AiOrchestrationServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -332,4 +455,4 @@ void generateShoppingItems_withBlankUserEmail_skipsUserLookupAndAppliesNonCatalo
assertThat(response.getItems().get(0).getCatalogId()).isNull();
verifyNoInteractions(userRepository);
}
}
}
14 changes: 14 additions & 0 deletions src/test/java/com/p2ps/util/QuantityParserTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading