feat(products): improve shopping suggestion decision support
This commit is contained in:
parent
5d9d18bd05
commit
bb5d402c15
8 changed files with 352 additions and 142 deletions
|
|
@ -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ć ją teraz, jak wpisuje się w obecną rutynę i jakie są 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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue