feat(products): improve shopping suggestion decision support

This commit is contained in:
Piotr Oleszczyk 2026-03-08 22:30:30 +01:00
parent 5d9d18bd05
commit bb5d402c15
8 changed files with 352 additions and 142 deletions

View file

@ -257,11 +257,16 @@ class InventoryUpdate(SQLModel):
class ProductSuggestion(PydanticBase):
category: ProductCategory
product_type: str
priority: Literal["high", "medium", "low"]
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: DayTime
frequency: str
short_reason: str
reason_to_buy_now: str
reason_not_needed_if_budget_tight: str | None = None
fit_with_current_routine: str
usage_cautions: list[str]
class ShoppingSuggestionResponse(PydanticBase):
@ -276,11 +281,16 @@ class ShoppingSuggestionResponse(PydanticBase):
class _ProductSuggestionOut(PydanticBase):
category: ProductCategory
product_type: str
priority: Literal["high", "medium", "low"]
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: DayTime
frequency: str
short_reason: str
reason_to_buy_now: str
reason_not_needed_if_budget_tight: str | None = None
fit_with_current_routine: str
usage_cautions: list[str]
class _ShoppingSuggestionsOut(PydanticBase):
@ -982,6 +992,13 @@ ZASADY:
8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów)
9. Odpowiadaj w języku polskim
10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
12. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić teraz, jak wpisuje się w obecną rutynę i jakie ograniczenia
13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade
DOZWOLONE WARTOŚCI ENUMÓW:
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
@ -1086,6 +1103,20 @@ def suggest_shopping(session: Session = Depends(get_session)):
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
parsed_response = _ShoppingSuggestionsOut.model_validate(parsed)
except ValidationError as exc:
formatted_errors = "; ".join(
f"{'/'.join(str(part) for part in err['loc'])}: {err['msg']}"
for err in exc.errors()
)
raise HTTPException(
status_code=502,
detail=(
f"LLM returned invalid shopping suggestion schema: {formatted_errors}"
),
)
# Get products with inventory (those user already owns)
products_with_inventory_ids = session.exec(
select(ProductInventory.product_id).distinct()
@ -1102,8 +1133,11 @@ def suggest_shopping(session: Session = Depends(get_session)):
# Build initial shopping response without metadata
shopping_response = ShoppingSuggestionResponse(
suggestions=[ProductSuggestion(**s) for s in parsed.get("suggestions", [])],
reasoning=parsed.get("reasoning", ""),
suggestions=[
ProductSuggestion.model_validate(s.model_dump())
for s in parsed_response.suggestions
],
reasoning=parsed_response.reasoning,
)
validation_result = validator.validate(shopping_response, shopping_context)

View file

@ -22,48 +22,9 @@ class ShoppingValidationContext:
class ShoppingValidator(BaseValidator):
"""Validates shopping suggestions for product types."""
"""Validates shopping suggestion schema and copy quality."""
# Realistic product type patterns (not exhaustive, just sanity checks)
VALID_PRODUCT_TYPE_PATTERNS = {
"serum",
"cream",
"cleanser",
"toner",
"essence",
"moisturizer",
"spf",
"sunscreen",
"oil",
"balm",
"mask",
"exfoliant",
"acid",
"retinoid",
"vitamin",
"niacinamide",
"hyaluronic",
"ceramide",
"peptide",
"antioxidant",
"aha",
"bha",
"pha",
}
VALID_FREQUENCIES = {
"daily",
"twice daily",
"am",
"pm",
"both",
"2x weekly",
"3x weekly",
"2-3x weekly",
"weekly",
"as needed",
"occasional",
}
VALID_PRIORITIES = {"high", "medium", "low"}
def validate(
self, response: Any, context: ShoppingValidationContext
@ -73,19 +34,17 @@ class ShoppingValidator(BaseValidator):
Checks:
1. suggestions field present
2. Product types are realistic (contain known keywords)
3. Not suggesting products user already owns (should mark as [])
4. Recommended frequencies are valid
5. Categories are valid
6. Targets are valid
7. Each suggestion has required fields
2. Categories are valid
3. Targets are valid
4. Each suggestion has required fields
5. Decision-support fields are well formed
Args:
response: Parsed shopping suggestion response
context: Validation context
Returns:
ValidationResult with any errors/warnings
ValidationResult with schema errors and lightweight quality warnings
"""
result = ValidationResult()
@ -112,15 +71,8 @@ class ShoppingValidator(BaseValidator):
f"Suggestion {sug_num}: invalid category '{suggestion.category}'"
)
# Check product type is realistic
if hasattr(suggestion, "product_type") and suggestion.product_type:
self._check_product_type_realistic(
suggestion.product_type, sug_num, result
)
# Check frequency is valid
if hasattr(suggestion, "frequency") and suggestion.frequency:
self._check_frequency_valid(suggestion.frequency, sug_num, result)
if hasattr(suggestion, "priority") and suggestion.priority:
self._check_priority_valid(suggestion.priority, sug_num, result)
# Check targets are valid
if hasattr(suggestion, "target_concerns") and suggestion.target_concerns:
@ -128,6 +80,11 @@ class ShoppingValidator(BaseValidator):
suggestion.target_concerns, sug_num, context, result
)
if hasattr(suggestion, "usage_cautions"):
self._check_usage_cautions(suggestion.usage_cautions, sug_num, result)
self._check_text_quality(suggestion, sug_num, result)
# Check recommended_time is valid
if hasattr(suggestion, "recommended_time") and suggestion.recommended_time:
if suggestion.recommended_time not in ("am", "pm", "both"):
@ -142,7 +99,15 @@ class ShoppingValidator(BaseValidator):
self, suggestion: Any, sug_num: int, result: ValidationResult
) -> None:
"""Check suggestion has required fields."""
required = ["category", "product_type", "why_needed"]
required = [
"category",
"product_type",
"priority",
"short_reason",
"reason_to_buy_now",
"fit_with_current_routine",
"usage_cautions",
]
for field in required:
if not hasattr(suggestion, field) or getattr(suggestion, field) is None:
@ -150,64 +115,14 @@ class ShoppingValidator(BaseValidator):
f"Suggestion {sug_num}: missing required field '{field}'"
)
def _check_product_type_realistic(
self, product_type: str, sug_num: int, result: ValidationResult
def _check_priority_valid(
self, priority: str, sug_num: int, result: ValidationResult
) -> None:
"""Check product type contains realistic keywords."""
product_type_lower = product_type.lower()
# Check if any valid pattern appears in the product type
has_valid_keyword = any(
pattern in product_type_lower
for pattern in self.VALID_PRODUCT_TYPE_PATTERNS
)
if not has_valid_keyword:
result.add_warning(
f"Suggestion {sug_num}: product type '{product_type}' looks unusual - "
"verify it's a real skincare product category"
)
# Check for brand names (shouldn't suggest specific brands)
suspicious_brands = [
"la roche",
"cerave",
"paula",
"ordinary",
"skinceuticals",
"drunk elephant",
"versed",
"inkey",
"cosrx",
"pixi",
]
if any(brand in product_type_lower for brand in suspicious_brands):
"""Check priority uses supported enum values."""
if priority not in self.VALID_PRIORITIES:
result.add_error(
f"Suggestion {sug_num}: product type contains brand name - "
"should suggest product TYPES only, not specific brands"
)
def _check_frequency_valid(
self, frequency: str, sug_num: int, result: ValidationResult
) -> None:
"""Check frequency is a recognized pattern."""
frequency_lower = frequency.lower()
# Check for exact matches or common patterns
is_valid = (
frequency_lower in self.VALID_FREQUENCIES
or "daily" in frequency_lower
or "weekly" in frequency_lower
or "am" in frequency_lower
or "pm" in frequency_lower
or "x" in frequency_lower # e.g. "2x weekly"
)
if not is_valid:
result.add_warning(
f"Suggestion {sug_num}: unusual frequency '{frequency}' - "
"verify it's a realistic usage pattern"
f"Suggestion {sug_num}: invalid priority '{priority}' "
"(must be 'high', 'medium', or 'low')"
)
def _check_targets_valid(
@ -227,3 +142,64 @@ class ShoppingValidator(BaseValidator):
result.add_error(
f"Suggestion {sug_num}: invalid target concern '{target}'"
)
def _check_usage_cautions(
self, usage_cautions: Any, sug_num: int, result: ValidationResult
) -> None:
"""Check usage cautions are a list of short strings."""
if not isinstance(usage_cautions, list):
result.add_error(f"Suggestion {sug_num}: usage_cautions must be a list")
return
for caution in usage_cautions:
if not isinstance(caution, str):
result.add_error(
f"Suggestion {sug_num}: usage_cautions entries must be strings"
)
continue
if len(caution.strip()) > 180:
result.add_warning(
f"Suggestion {sug_num}: usage caution is too long - keep it concise"
)
def _check_text_quality(
self, suggestion: Any, sug_num: int, result: ValidationResult
) -> None:
"""Warn when decision-support copy is too generic or empty-ish."""
generic_phrases = {
"wspiera skore",
"pomaga skorze",
"moze pomoc",
"dobry wybor",
"uzupelnia rutyne",
"supports the skin",
"may help",
"good option",
"complements the routine",
}
text_fields = [
("short_reason", getattr(suggestion, "short_reason", None), 12),
("reason_to_buy_now", getattr(suggestion, "reason_to_buy_now", None), 18),
(
"fit_with_current_routine",
getattr(suggestion, "fit_with_current_routine", None),
18,
),
]
for field_name, value, min_length in text_fields:
if not isinstance(value, str):
continue
stripped = value.strip()
if len(stripped) < min_length:
result.add_warning(
f"Suggestion {sug_num}: {field_name} is very short - add more decision context"
)
continue
lowered = stripped.lower()
if lowered in generic_phrases:
result.add_warning(
f"Suggestion {sug_num}: {field_name} is too generic - make it more specific"
)