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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,17 +5,25 @@ from unittest.mock import patch
|
|||
from sqlmodel import Session
|
||||
|
||||
from innercontext.api.products import (
|
||||
ProductSuggestion,
|
||||
ShoppingSuggestionResponse,
|
||||
_build_shopping_context,
|
||||
_extract_requested_product_ids,
|
||||
build_product_details_tool_handler,
|
||||
)
|
||||
from innercontext.models import (
|
||||
Product,
|
||||
ProductCategory,
|
||||
ProductInventory,
|
||||
SexAtBirth,
|
||||
SkinConcern,
|
||||
SkinConditionSnapshot,
|
||||
)
|
||||
from innercontext.models.profile import UserProfile
|
||||
from innercontext.validators.shopping_validator import (
|
||||
ShoppingValidationContext,
|
||||
ShoppingValidator,
|
||||
)
|
||||
|
||||
|
||||
def test_build_shopping_context(session: Session):
|
||||
|
|
@ -46,6 +54,7 @@ def test_build_shopping_context(session: Session):
|
|||
# Add product
|
||||
p = Product(
|
||||
id=uuid.uuid4(),
|
||||
short_id=str(uuid.uuid4())[:8],
|
||||
name="Soothing Serum",
|
||||
brand="BrandX",
|
||||
category="serum",
|
||||
|
|
@ -108,7 +117,7 @@ def test_suggest_shopping(client, session):
|
|||
"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)
|
||||
|
|
@ -118,6 +127,11 @@ def test_suggest_shopping(client, session):
|
|||
data = r.json()
|
||||
assert len(data["suggestions"]) == 1
|
||||
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"
|
||||
kwargs = mock_gemini.call_args.kwargs
|
||||
assert "USER PROFILE:" in kwargs["contents"]
|
||||
|
|
@ -133,9 +147,43 @@ def test_suggest_shopping(client, session):
|
|||
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):
|
||||
p = Product(
|
||||
id=uuid.uuid4(),
|
||||
short_id=str(uuid.uuid4())[:8],
|
||||
name="Epiduo",
|
||||
brand="Galderma",
|
||||
category="serum",
|
||||
|
|
@ -162,6 +210,7 @@ def test_extract_requested_product_ids_dedupes_and_limits():
|
|||
def test_shopping_tool_handlers_return_payloads(session: Session):
|
||||
product = Product(
|
||||
id=uuid.uuid4(),
|
||||
short_id=str(uuid.uuid4())[:8],
|
||||
name="Test Product",
|
||||
brand="Brand",
|
||||
category="serum",
|
||||
|
|
@ -176,10 +225,10 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
|
|||
payload = {"product_ids": [str(product.id)]}
|
||||
|
||||
details = build_product_details_tool_handler([product])(payload)
|
||||
assert details["products"][0]["inci"] == ["Water", "Niacinamide"]
|
||||
assert details["products"][0]["actives"][0]["name"] == "Niacinamide"
|
||||
assert "context_rules" in details["products"][0]
|
||||
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):
|
||||
|
|
@ -200,3 +249,48 @@ def test_shopping_tool_handler_includes_last_used_on_from_mapping(session: Sessi
|
|||
)(payload)
|
||||
|
||||
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_suggestTime": "Time",
|
||||
"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_suggestNoResults": "No suggestions.",
|
||||
"products_noProducts": "No products found.",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@
|
|||
"products_suggestResults": "Propozycje",
|
||||
"products_suggestTime": "Pora",
|
||||
"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_suggestNoResults": "Brak propozycji.",
|
||||
"products_noProducts": "Nie znaleziono produktów.",
|
||||
|
|
|
|||
|
|
@ -284,14 +284,21 @@ export interface BatchSuggestion {
|
|||
|
||||
// ─── Shopping suggestion types ───────────────────────────────────────────────
|
||||
|
||||
export type ShoppingPriority = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface ProductSuggestion {
|
||||
category: string;
|
||||
product_type: string;
|
||||
priority: ShoppingPriority;
|
||||
key_ingredients: string[];
|
||||
target_concerns: string[];
|
||||
why_needed: string;
|
||||
recommended_time: 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 {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export const actions: Actions = {
|
|||
return {
|
||||
suggestions: data.suggestions,
|
||||
reasoning: data.reasoning,
|
||||
validation_warnings: data.validation_warnings,
|
||||
auto_fixes_applied: data.auto_fixes_applied,
|
||||
response_metadata: data.response_metadata,
|
||||
};
|
||||
} catch (e) {
|
||||
return fail(500, { error: String(e) });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
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 { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
|
@ -22,6 +22,28 @@
|
|||
let autoFixes = $state<string[] | 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() {
|
||||
loading = true;
|
||||
errorMsg = null;
|
||||
|
|
@ -104,37 +126,93 @@
|
|||
<div class="space-y-4 reveal-3">
|
||||
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
|
||||
{#each suggestions as s (s.product_type)}
|
||||
<Card>
|
||||
<Card class="border-border/80 bg-card/95 shadow-sm">
|
||||
<CardContent class="pt-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h4 class="font-medium">{s.product_type}</h4>
|
||||
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<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 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}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each s.key_ingredients as ing (ing)}
|
||||
<Badge variant="outline" class="text-xs">{ing}</Badge>
|
||||
{/each}
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{m.products_suggestKeyIngredients()}
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
{#if s.target_concerns.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each s.target_concerns as concern (concern)}
|
||||
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge>
|
||||
{/each}
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{m.products_suggestTargets()}
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
<p class="text-sm text-muted-foreground">{s.why_needed}</p>
|
||||
|
||||
<div class="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
|
||||
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
|
||||
{#if s.usage_cautions.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{m.products_suggestCautions()}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue