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): 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ć 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: 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)

View file

@ -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"
)

View file

@ -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)

View file

@ -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.",

View file

@ -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.",

View file

@ -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 {

View file

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

View file

@ -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">
<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> <Badge variant="secondary" class="shrink-0">{s.category}</Badge>
</div> </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} {#if s.key_ingredients.length > 0}
<div class="flex flex-wrap gap-1"> <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)} {#each s.key_ingredients as ing (ing)}
<Badge variant="outline" class="text-xs">{ing}</Badge> <Badge variant="outline" class="text-[11px] normal-case tracking-normal">{ing}</Badge>
{/each} {/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">
<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)} {#each s.target_concerns as concern (concern)}
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge> <Badge class="text-[11px] normal-case tracking-normal">{concern.replace(/_/g, ' ')}</Badge>
{/each} {/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>
</div> </div>
{/if}
</div>
</CardContent> </CardContent>
</Card> </Card>
{/each} {/each}