feat(products): improve shopping suggestion decision support

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
<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">
<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-xs">{ing}</Badge>
<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">
<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-xs">{concern.replace(/_/g, ' ')}</Badge>
<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>
</CardContent>
</Card>
{/each}