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):
|
class ProductSuggestion(PydanticBase):
|
||||||
category: ProductCategory
|
category: ProductCategory
|
||||||
product_type: str
|
product_type: str
|
||||||
|
priority: Literal["high", "medium", "low"]
|
||||||
key_ingredients: list[str]
|
key_ingredients: list[str]
|
||||||
target_concerns: list[str]
|
target_concerns: list[str]
|
||||||
why_needed: str
|
|
||||||
recommended_time: DayTime
|
recommended_time: DayTime
|
||||||
frequency: str
|
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):
|
class ShoppingSuggestionResponse(PydanticBase):
|
||||||
|
|
@ -276,11 +281,16 @@ class ShoppingSuggestionResponse(PydanticBase):
|
||||||
class _ProductSuggestionOut(PydanticBase):
|
class _ProductSuggestionOut(PydanticBase):
|
||||||
category: ProductCategory
|
category: ProductCategory
|
||||||
product_type: str
|
product_type: str
|
||||||
|
priority: Literal["high", "medium", "low"]
|
||||||
key_ingredients: list[str]
|
key_ingredients: list[str]
|
||||||
target_concerns: list[str]
|
target_concerns: list[str]
|
||||||
why_needed: str
|
|
||||||
recommended_time: DayTime
|
recommended_time: DayTime
|
||||||
frequency: str
|
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):
|
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)
|
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
|
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"
|
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:
|
DOZWOLONE WARTOŚCI ENUMÓW:
|
||||||
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
|
- 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:
|
except json.JSONDecodeError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {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)
|
# Get products with inventory (those user already owns)
|
||||||
products_with_inventory_ids = session.exec(
|
products_with_inventory_ids = session.exec(
|
||||||
select(ProductInventory.product_id).distinct()
|
select(ProductInventory.product_id).distinct()
|
||||||
|
|
@ -1102,8 +1133,11 @@ def suggest_shopping(session: Session = Depends(get_session)):
|
||||||
|
|
||||||
# Build initial shopping response without metadata
|
# Build initial shopping response without metadata
|
||||||
shopping_response = ShoppingSuggestionResponse(
|
shopping_response = ShoppingSuggestionResponse(
|
||||||
suggestions=[ProductSuggestion(**s) for s in parsed.get("suggestions", [])],
|
suggestions=[
|
||||||
reasoning=parsed.get("reasoning", ""),
|
ProductSuggestion.model_validate(s.model_dump())
|
||||||
|
for s in parsed_response.suggestions
|
||||||
|
],
|
||||||
|
reasoning=parsed_response.reasoning,
|
||||||
)
|
)
|
||||||
|
|
||||||
validation_result = validator.validate(shopping_response, shopping_context)
|
validation_result = validator.validate(shopping_response, shopping_context)
|
||||||
|
|
|
||||||
|
|
@ -22,48 +22,9 @@ class ShoppingValidationContext:
|
||||||
|
|
||||||
|
|
||||||
class ShoppingValidator(BaseValidator):
|
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_PRIORITIES = {"high", "medium", "low"}
|
||||||
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
def validate(
|
def validate(
|
||||||
self, response: Any, context: ShoppingValidationContext
|
self, response: Any, context: ShoppingValidationContext
|
||||||
|
|
@ -73,19 +34,17 @@ class ShoppingValidator(BaseValidator):
|
||||||
|
|
||||||
Checks:
|
Checks:
|
||||||
1. suggestions field present
|
1. suggestions field present
|
||||||
2. Product types are realistic (contain known keywords)
|
2. Categories are valid
|
||||||
3. Not suggesting products user already owns (should mark as [✗])
|
3. Targets are valid
|
||||||
4. Recommended frequencies are valid
|
4. Each suggestion has required fields
|
||||||
5. Categories are valid
|
5. Decision-support fields are well formed
|
||||||
6. Targets are valid
|
|
||||||
7. Each suggestion has required fields
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: Parsed shopping suggestion response
|
response: Parsed shopping suggestion response
|
||||||
context: Validation context
|
context: Validation context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ValidationResult with any errors/warnings
|
ValidationResult with schema errors and lightweight quality warnings
|
||||||
"""
|
"""
|
||||||
result = ValidationResult()
|
result = ValidationResult()
|
||||||
|
|
||||||
|
|
@ -112,15 +71,8 @@ class ShoppingValidator(BaseValidator):
|
||||||
f"Suggestion {sug_num}: invalid category '{suggestion.category}'"
|
f"Suggestion {sug_num}: invalid category '{suggestion.category}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check product type is realistic
|
if hasattr(suggestion, "priority") and suggestion.priority:
|
||||||
if hasattr(suggestion, "product_type") and suggestion.product_type:
|
self._check_priority_valid(suggestion.priority, sug_num, result)
|
||||||
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)
|
|
||||||
|
|
||||||
# Check targets are valid
|
# Check targets are valid
|
||||||
if hasattr(suggestion, "target_concerns") and suggestion.target_concerns:
|
if hasattr(suggestion, "target_concerns") and suggestion.target_concerns:
|
||||||
|
|
@ -128,6 +80,11 @@ class ShoppingValidator(BaseValidator):
|
||||||
suggestion.target_concerns, sug_num, context, result
|
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
|
# Check recommended_time is valid
|
||||||
if hasattr(suggestion, "recommended_time") and suggestion.recommended_time:
|
if hasattr(suggestion, "recommended_time") and suggestion.recommended_time:
|
||||||
if suggestion.recommended_time not in ("am", "pm", "both"):
|
if suggestion.recommended_time not in ("am", "pm", "both"):
|
||||||
|
|
@ -142,7 +99,15 @@ class ShoppingValidator(BaseValidator):
|
||||||
self, suggestion: Any, sug_num: int, result: ValidationResult
|
self, suggestion: Any, sug_num: int, result: ValidationResult
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check suggestion has required fields."""
|
"""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:
|
for field in required:
|
||||||
if not hasattr(suggestion, field) or getattr(suggestion, field) is None:
|
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}'"
|
f"Suggestion {sug_num}: missing required field '{field}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_product_type_realistic(
|
def _check_priority_valid(
|
||||||
self, product_type: str, sug_num: int, result: ValidationResult
|
self, priority: str, sug_num: int, result: ValidationResult
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check product type contains realistic keywords."""
|
"""Check priority uses supported enum values."""
|
||||||
product_type_lower = product_type.lower()
|
if priority not in self.VALID_PRIORITIES:
|
||||||
|
|
||||||
# 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):
|
|
||||||
result.add_error(
|
result.add_error(
|
||||||
f"Suggestion {sug_num}: product type contains brand name - "
|
f"Suggestion {sug_num}: invalid priority '{priority}' "
|
||||||
"should suggest product TYPES only, not specific brands"
|
"(must be 'high', 'medium', or 'low')"
|
||||||
)
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_targets_valid(
|
def _check_targets_valid(
|
||||||
|
|
@ -227,3 +142,64 @@ class ShoppingValidator(BaseValidator):
|
||||||
result.add_error(
|
result.add_error(
|
||||||
f"Suggestion {sug_num}: invalid target concern '{target}'"
|
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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,25 @@ from unittest.mock import patch
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from innercontext.api.products import (
|
from innercontext.api.products import (
|
||||||
|
ProductSuggestion,
|
||||||
|
ShoppingSuggestionResponse,
|
||||||
_build_shopping_context,
|
_build_shopping_context,
|
||||||
_extract_requested_product_ids,
|
_extract_requested_product_ids,
|
||||||
build_product_details_tool_handler,
|
build_product_details_tool_handler,
|
||||||
)
|
)
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
Product,
|
Product,
|
||||||
|
ProductCategory,
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
SexAtBirth,
|
SexAtBirth,
|
||||||
|
SkinConcern,
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
)
|
)
|
||||||
from innercontext.models.profile import UserProfile
|
from innercontext.models.profile import UserProfile
|
||||||
|
from innercontext.validators.shopping_validator import (
|
||||||
|
ShoppingValidationContext,
|
||||||
|
ShoppingValidator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_shopping_context(session: Session):
|
def test_build_shopping_context(session: Session):
|
||||||
|
|
@ -46,6 +54,7 @@ def test_build_shopping_context(session: Session):
|
||||||
# Add product
|
# Add product
|
||||||
p = Product(
|
p = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
short_id=str(uuid.uuid4())[:8],
|
||||||
name="Soothing Serum",
|
name="Soothing Serum",
|
||||||
brand="BrandX",
|
brand="BrandX",
|
||||||
category="serum",
|
category="serum",
|
||||||
|
|
@ -108,7 +117,7 @@ def test_suggest_shopping(client, session):
|
||||||
"Response",
|
"Response",
|
||||||
(),
|
(),
|
||||||
{
|
{
|
||||||
"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}'
|
"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": ["glycerin"], "target_concerns": ["dehydration"], "recommended_time": "am", "frequency": "daily", "short_reason": "Brakuje lagodnego kroku myjacego rano.", "reason_to_buy_now": "Obecnie nie masz delikatnego produktu do porannego oczyszczania i wsparcia bariery.", "reason_not_needed_if_budget_tight": "Mozesz tymczasowo ograniczyc sie do samego splukania twarzy rano, jesli skora jest spokojna.", "fit_with_current_routine": "To domknie podstawowy krok cleanse bez dokladania agresywnych aktywow.", "usage_cautions": ["unikaj mocnego domywania przy podraznieniu"]}], "reasoning": "Test shopping"}'
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mock_gemini.return_value = (mock_response, None)
|
mock_gemini.return_value = (mock_response, None)
|
||||||
|
|
@ -118,6 +127,11 @@ def test_suggest_shopping(client, session):
|
||||||
data = r.json()
|
data = r.json()
|
||||||
assert len(data["suggestions"]) == 1
|
assert len(data["suggestions"]) == 1
|
||||||
assert data["suggestions"][0]["product_type"] == "cleanser"
|
assert data["suggestions"][0]["product_type"] == "cleanser"
|
||||||
|
assert data["suggestions"][0]["priority"] == "high"
|
||||||
|
assert data["suggestions"][0]["short_reason"]
|
||||||
|
assert data["suggestions"][0]["usage_cautions"] == [
|
||||||
|
"unikaj mocnego domywania przy podraznieniu"
|
||||||
|
]
|
||||||
assert data["reasoning"] == "Test shopping"
|
assert data["reasoning"] == "Test shopping"
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
assert "USER PROFILE:" in kwargs["contents"]
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
|
|
@ -133,9 +147,43 @@ def test_suggest_shopping(client, session):
|
||||||
assert "get_product_details" in kwargs["function_handlers"]
|
assert "get_product_details" in kwargs["function_handlers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_shopping_invalid_json_returns_502(client):
|
||||||
|
with patch(
|
||||||
|
"innercontext.api.products.call_gemini_with_function_tools"
|
||||||
|
) as mock_gemini:
|
||||||
|
mock_response = type("Response", (), {"text": "{"})
|
||||||
|
mock_gemini.return_value = (mock_response, None)
|
||||||
|
|
||||||
|
r = client.post("/products/suggest")
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert "LLM returned invalid JSON" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_shopping_invalid_schema_returns_502(client):
|
||||||
|
with patch(
|
||||||
|
"innercontext.api.products.call_gemini_with_function_tools"
|
||||||
|
) as mock_gemini:
|
||||||
|
mock_response = type(
|
||||||
|
"Response",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "urgent", "key_ingredients": [], "target_concerns": [], "recommended_time": "am", "frequency": "daily", "short_reason": "x", "reason_to_buy_now": "y", "fit_with_current_routine": "z", "usage_cautions": []}], "reasoning": "Test shopping"}'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_gemini.return_value = (mock_response, None)
|
||||||
|
|
||||||
|
r = client.post("/products/suggest")
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert "LLM returned invalid shopping suggestion schema" in r.json()["detail"]
|
||||||
|
assert "suggestions/0/priority" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_shopping_context_medication_skip(session: Session):
|
def test_shopping_context_medication_skip(session: Session):
|
||||||
p = Product(
|
p = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
short_id=str(uuid.uuid4())[:8],
|
||||||
name="Epiduo",
|
name="Epiduo",
|
||||||
brand="Galderma",
|
brand="Galderma",
|
||||||
category="serum",
|
category="serum",
|
||||||
|
|
@ -162,6 +210,7 @@ def test_extract_requested_product_ids_dedupes_and_limits():
|
||||||
def test_shopping_tool_handlers_return_payloads(session: Session):
|
def test_shopping_tool_handlers_return_payloads(session: Session):
|
||||||
product = Product(
|
product = Product(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
short_id=str(uuid.uuid4())[:8],
|
||||||
name="Test Product",
|
name="Test Product",
|
||||||
brand="Brand",
|
brand="Brand",
|
||||||
category="serum",
|
category="serum",
|
||||||
|
|
@ -176,10 +225,10 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
|
||||||
payload = {"product_ids": [str(product.id)]}
|
payload = {"product_ids": [str(product.id)]}
|
||||||
|
|
||||||
details = build_product_details_tool_handler([product])(payload)
|
details = build_product_details_tool_handler([product])(payload)
|
||||||
assert details["products"][0]["inci"] == ["Water", "Niacinamide"]
|
|
||||||
assert details["products"][0]["actives"][0]["name"] == "Niacinamide"
|
assert details["products"][0]["actives"][0]["name"] == "Niacinamide"
|
||||||
assert "context_rules" in details["products"][0]
|
assert "context_rules" in details["products"][0]
|
||||||
assert details["products"][0]["last_used_on"] is None
|
assert details["products"][0]["last_used_on"] is None
|
||||||
|
assert "inci" not in details["products"][0]
|
||||||
|
|
||||||
|
|
||||||
def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Session):
|
def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Session):
|
||||||
|
|
@ -200,3 +249,48 @@ def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Sessi
|
||||||
)(payload)
|
)(payload)
|
||||||
|
|
||||||
assert details["products"][0]["last_used_on"] == "2026-03-01"
|
assert details["products"][0]["last_used_on"] == "2026-03-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shopping_validator_accepts_freeform_product_type_and_frequency():
|
||||||
|
response = ShoppingSuggestionResponse(
|
||||||
|
suggestions=[
|
||||||
|
ProductSuggestion(
|
||||||
|
category="spot_treatment",
|
||||||
|
product_type="Punktowy preparat na wypryski z ichtiolem lub cynkiem",
|
||||||
|
priority="high",
|
||||||
|
key_ingredients=["ichtiol", "cynk"],
|
||||||
|
target_concerns=["acne"],
|
||||||
|
recommended_time="pm",
|
||||||
|
frequency="Codziennie (punktowo na zmiany)",
|
||||||
|
short_reason="Pomaga opanowac aktywne zmiany bez dokladania pelnego aktywu na cala twarz.",
|
||||||
|
reason_to_buy_now="Brakuje Ci dedykowanego produktu punktowego na pojedyncze wypryski.",
|
||||||
|
fit_with_current_routine="Mozesz dolozyc go tylko na zmiany po serum lub zamiast mocniejszego aktywu.",
|
||||||
|
usage_cautions=["stosuj tylko miejscowo"],
|
||||||
|
),
|
||||||
|
ProductSuggestion(
|
||||||
|
category="mask",
|
||||||
|
product_type="Lagodna maska oczyszczajaca",
|
||||||
|
priority="low",
|
||||||
|
key_ingredients=["glinka"],
|
||||||
|
target_concerns=["sebum_excess"],
|
||||||
|
recommended_time="pm",
|
||||||
|
frequency="1 raz w tygodniu",
|
||||||
|
short_reason="To opcjonalne wsparcie przy nadmiarze sebum.",
|
||||||
|
reason_to_buy_now="Moze pomoc domknac sporadyczne oczyszczanie, gdy skora jest bardziej przetluszczona.",
|
||||||
|
fit_with_current_routine="Najlepiej traktowac to jako dodatkowy krok, nie zamiennik podstaw rutyny.",
|
||||||
|
usage_cautions=[],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
reasoning="Test",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ShoppingValidator().validate(
|
||||||
|
response,
|
||||||
|
ShoppingValidationContext(
|
||||||
|
owned_product_ids=set(),
|
||||||
|
valid_categories=set(ProductCategory),
|
||||||
|
valid_targets=set(SkinConcern),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not any("unusual frequency" in warning for warning in result.warnings)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@
|
||||||
"products_suggestResults": "Suggestions",
|
"products_suggestResults": "Suggestions",
|
||||||
"products_suggestTime": "Time",
|
"products_suggestTime": "Time",
|
||||||
"products_suggestFrequency": "Frequency",
|
"products_suggestFrequency": "Frequency",
|
||||||
|
"products_suggestPriorityHigh": "High priority",
|
||||||
|
"products_suggestPriorityMedium": "Medium priority",
|
||||||
|
"products_suggestPriorityLow": "Low priority",
|
||||||
|
"products_suggestBuyNow": "Buy now because",
|
||||||
|
"products_suggestRoutineFit": "How it fits your routine",
|
||||||
|
"products_suggestBudgetSkip": "If you're cutting the budget",
|
||||||
|
"products_suggestKeyIngredients": "Key ingredients",
|
||||||
|
"products_suggestTargets": "Targets",
|
||||||
|
"products_suggestCautions": "Cautions",
|
||||||
"products_suggestRegenerate": "Regenerate",
|
"products_suggestRegenerate": "Regenerate",
|
||||||
"products_suggestNoResults": "No suggestions.",
|
"products_suggestNoResults": "No suggestions.",
|
||||||
"products_noProducts": "No products found.",
|
"products_noProducts": "No products found.",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,15 @@
|
||||||
"products_suggestResults": "Propozycje",
|
"products_suggestResults": "Propozycje",
|
||||||
"products_suggestTime": "Pora",
|
"products_suggestTime": "Pora",
|
||||||
"products_suggestFrequency": "Częstotliwość",
|
"products_suggestFrequency": "Częstotliwość",
|
||||||
|
"products_suggestPriorityHigh": "Wysoki priorytet",
|
||||||
|
"products_suggestPriorityMedium": "Średni priorytet",
|
||||||
|
"products_suggestPriorityLow": "Niski priorytet",
|
||||||
|
"products_suggestBuyNow": "Kup teraz, bo",
|
||||||
|
"products_suggestRoutineFit": "Jak wpisuje się w rutynę",
|
||||||
|
"products_suggestBudgetSkip": "Jeśli tniesz budżet",
|
||||||
|
"products_suggestKeyIngredients": "Kluczowe składniki",
|
||||||
|
"products_suggestTargets": "Cele",
|
||||||
|
"products_suggestCautions": "Uwagi",
|
||||||
"products_suggestRegenerate": "Wygeneruj ponownie",
|
"products_suggestRegenerate": "Wygeneruj ponownie",
|
||||||
"products_suggestNoResults": "Brak propozycji.",
|
"products_suggestNoResults": "Brak propozycji.",
|
||||||
"products_noProducts": "Nie znaleziono produktów.",
|
"products_noProducts": "Nie znaleziono produktów.",
|
||||||
|
|
|
||||||
|
|
@ -284,14 +284,21 @@ export interface BatchSuggestion {
|
||||||
|
|
||||||
// ─── Shopping suggestion types ───────────────────────────────────────────────
|
// ─── Shopping suggestion types ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ShoppingPriority = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
export interface ProductSuggestion {
|
export interface ProductSuggestion {
|
||||||
category: string;
|
category: string;
|
||||||
product_type: string;
|
product_type: string;
|
||||||
|
priority: ShoppingPriority;
|
||||||
key_ingredients: string[];
|
key_ingredients: string[];
|
||||||
target_concerns: string[];
|
target_concerns: string[];
|
||||||
why_needed: string;
|
|
||||||
recommended_time: string;
|
recommended_time: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
short_reason: string;
|
||||||
|
reason_to_buy_now: string;
|
||||||
|
reason_not_needed_if_budget_tight?: string;
|
||||||
|
fit_with_current_routine: string;
|
||||||
|
usage_cautions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingSuggestionResponse {
|
export interface ShoppingSuggestionResponse {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export const actions: Actions = {
|
||||||
return {
|
return {
|
||||||
suggestions: data.suggestions,
|
suggestions: data.suggestions,
|
||||||
reasoning: data.reasoning,
|
reasoning: data.reasoning,
|
||||||
|
validation_warnings: data.validation_warnings,
|
||||||
|
auto_fixes_applied: data.auto_fixes_applied,
|
||||||
|
response_metadata: data.response_metadata,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(500, { error: String(e) });
|
return fail(500, { error: String(e) });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ProductSuggestion, ResponseMetadata } from '$lib/types';
|
import type { ProductSuggestion, ResponseMetadata, ShoppingPriority } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
@ -22,6 +22,28 @@
|
||||||
let autoFixes = $state<string[] | undefined>(undefined);
|
let autoFixes = $state<string[] | undefined>(undefined);
|
||||||
let responseMetadata = $state<ResponseMetadata | undefined>(undefined);
|
let responseMetadata = $state<ResponseMetadata | undefined>(undefined);
|
||||||
|
|
||||||
|
function priorityLabel(priority: ShoppingPriority) {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return m.products_suggestPriorityHigh();
|
||||||
|
case 'medium':
|
||||||
|
return m.products_suggestPriorityMedium();
|
||||||
|
default:
|
||||||
|
return m.products_suggestPriorityLow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityClasses(priority: ShoppingPriority) {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return 'border-transparent bg-[color:var(--page-accent)] text-white';
|
||||||
|
case 'medium':
|
||||||
|
return 'border-[color:var(--page-accent)]/20 bg-[var(--page-accent-soft)] text-foreground';
|
||||||
|
default:
|
||||||
|
return 'border-border bg-secondary text-secondary-foreground';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function enhanceForm() {
|
function enhanceForm() {
|
||||||
loading = true;
|
loading = true;
|
||||||
errorMsg = null;
|
errorMsg = null;
|
||||||
|
|
@ -104,37 +126,93 @@
|
||||||
<div class="space-y-4 reveal-3">
|
<div class="space-y-4 reveal-3">
|
||||||
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
|
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
|
||||||
{#each suggestions as s (s.product_type)}
|
{#each suggestions as s (s.product_type)}
|
||||||
<Card>
|
<Card class="border-border/80 bg-card/95 shadow-sm">
|
||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h4 class="font-medium">{s.product_type}</h4>
|
<div class="space-y-2">
|
||||||
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge class={priorityClasses(s.priority)}>{priorityLabel(s.priority)}</Badge>
|
||||||
|
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium tracking-[0.01em]">{s.product_type}</h4>
|
||||||
|
<p class="text-sm text-foreground/80">{s.short_reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 text-xs text-muted-foreground sm:justify-end">
|
||||||
|
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
|
||||||
|
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{m.products_suggestBuyNow()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-foreground/85">{s.reason_to_buy_now}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{m.products_suggestRoutineFit()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-foreground/85">{s.fit_with_current_routine}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if s.reason_not_needed_if_budget_tight}
|
||||||
|
<div class="rounded-xl border border-dashed border-border/80 bg-background p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{m.products_suggestBudgetSkip()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-foreground/80">
|
||||||
|
{s.reason_not_needed_if_budget_tight}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if s.key_ingredients.length > 0}
|
{#if s.key_ingredients.length > 0}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="space-y-2">
|
||||||
{#each s.key_ingredients as ing (ing)}
|
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
<Badge variant="outline" class="text-xs">{ing}</Badge>
|
{m.products_suggestKeyIngredients()}
|
||||||
{/each}
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each s.key_ingredients as ing (ing)}
|
||||||
|
<Badge variant="outline" class="text-[11px] normal-case tracking-normal">{ing}</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if s.target_concerns.length > 0}
|
{#if s.target_concerns.length > 0}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="space-y-2">
|
||||||
{#each s.target_concerns as concern (concern)}
|
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge>
|
{m.products_suggestTargets()}
|
||||||
{/each}
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each s.target_concerns as concern (concern)}
|
||||||
|
<Badge class="text-[11px] normal-case tracking-normal">{concern.replace(/_/g, ' ')}</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="text-sm text-muted-foreground">{s.why_needed}</p>
|
{#if s.usage_cautions.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="flex gap-4 text-xs text-muted-foreground">
|
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
|
{m.products_suggestCautions()}
|
||||||
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each s.usage_cautions as caution (caution)}
|
||||||
|
<Badge variant="outline" class="border-amber-200 bg-amber-50 text-[11px] normal-case tracking-normal text-amber-900">
|
||||||
|
{caution}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue