fix(api): tighten shopping suggestion response rules

Constrain shopping target concerns to SkinConcern enums and add a regression test for invalid values. Simplify the shopping prompt so repurchase suggestions stay practical, use shorter product types, and avoid leaking raw scoring/debug language into user-facing copy.
This commit is contained in:
Piotr Oleszczyk 2026-03-09 17:26:24 +01:00
parent d91d06455b
commit e20c18c2ee
2 changed files with 37 additions and 41 deletions

View file

@ -416,7 +416,7 @@ class ProductSuggestion(PydanticBase):
product_type: str product_type: str
priority: Literal["high", "medium", "low"] priority: Literal["high", "medium", "low"]
key_ingredients: list[str] key_ingredients: list[str]
target_concerns: list[str] target_concerns: list[SkinConcern]
recommended_time: DayTime recommended_time: DayTime
frequency: str frequency: str
short_reason: str short_reason: str
@ -440,7 +440,7 @@ class _ProductSuggestionOut(PydanticBase):
product_type: str product_type: str
priority: Literal["high", "medium", "low"] priority: Literal["high", "medium", "low"]
key_ingredients: list[str] key_ingredients: list[str]
target_concerns: list[str] target_concerns: list[SkinConcern]
recommended_time: DayTime recommended_time: DayTime
frequency: str frequency: str
short_reason: str short_reason: str
@ -1189,48 +1189,24 @@ def _extract_requested_product_ids(
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry. _SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Twoim zadaniem jest ocenić dwie rzeczy: Oceń dwie rzeczy: realne luki w rutynie oraz odkupy produktów, które warto uzupełnić teraz.
1. czy w rutynie użytkownika istnieją realne luki produktowe, Działaj konserwatywnie: sugeruj tylko wtedy, gdy istnieje wyraźny powód praktyczny.
2. czy któryś z już posiadanych typów produktów warto odkupić teraz z powodu kończącego się zapasu. Najpierw rozważ luki w rutynie, potem odkupy.
Traktuj `replenishment_score`, `replenishment_priority_hint`, `repurchase_candidate`, `stock_state`, `lowest_remaining_level`, `sealed_backup_count`, `last_used_on` i `days_since_last_used` jako główne sygnały decyzji zakupowej.
Masz działać konserwatywnie: nie sugeruj zakupu tylko dlatego, że coś mogłoby się przydać. `sealed_backup_count` odnosi się do zapasu tego produktu lub bardzo zbliżonego typu; inny produkt z tej samej kategorii obniża pilność tylko wtedy, gdy realistycznie pełni podobną funkcję w rutynie.
Sugestia ma pojawić się tylko wtedy, gdy istnieje wyraźny powód praktyczny. Jeśli zakup nie jest pilny dzięki alternatywie, wyjaśnij, czy chodzi o rzeczywisty zapas tego samego typu produktu, czy o funkcjonalny zamiennik z tej samej kategorii.
Jeśli istnieje sealed backup lub bardzo bliski funkcjonalny zamiennik, sugestia zwykle nie powinna mieć `priority=high`, chyba że potrzeba odkupu jest wyraźnie wyjątkowa.
WAŻNE POJĘCIA: `product_type` ma być krótką nazwą typu produktu i opisywać funkcję produktu, a nie opis marketingowy lub pełną specyfikację składu.
- `stock_state` opisuje stan zapasu: `out_of_stock`, `healthy`, `monitor`, `low`, `urgent` Przy odkupie możesz odwoływać się do konkretnych już posiadanych produktów, jeśli pomaga to uzasadnić decyzję. Przy lukach w rutynie sugeruj typy produktów, nie marki.
- `sealed_backup_count` > 0 zwykle oznacza, że odkup nie jest pilny Uwzględniaj aktywne problemy skóry, miejsce produktu w rutynie, konflikty składników i bezpieczeństwo przy naruszonej barierze.
- `lowest_remaining_level` dotyczy tylko otwartych opakowań i może mieć wartość: `high`, `medium`, `low`, `nearly_empty` Pisz po polsku, językiem praktycznym i zakupowym. Unikaj nadmiernie medycznego lub diagnostycznego tonu.
- `last_used_on` i `days_since_last_used` pomagają ocenić, czy produkt jest nadal faktycznie używany Nie używaj w tekstach dla użytkownika surowych sygnałów systemowych ani dosłownych etykiet z warstwy danych, takich jak `id`, `score`, `status low` czy `poziom produktu jest niski`; opisuj naturalnie wniosek, np. jako kończący się zapas, niski zapas lub wysoką pilność odkupu.
- `replenishment_score`, `replenishment_priority_hint` i `repurchase_candidate` backendowym sygnałem pilności odkupu; traktuj je jako mocną wskazówkę Możesz zwrócić pustą listę `suggestions`, jeśli nie widzisz realnej potrzeby.
- `reason_codes` tłumaczą, dlaczego backend uznał odkup za pilny albo niepilny `target_concerns` musi używać wyłącznie wartości enumu `SkinConcern` poniżej. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade.
JAK PODEJMOWAĆ DECYZJĘ:
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
1. Najpierw oceń, czy użytkownik ma lukę w rutynie
2. Potem oceń, czy któryś istniejący typ produktu jest wart odkupu teraz
3. Nie rekomenduj ponownie produktu, który ma zdrowy zapas
4. Nie rekomenduj odkupu tylko dlatego, że produkt jest niski, jeśli nie był używany od dawna i nie wygląda na nadal potrzebny
5. Dla kategorii podstawowych (`cleanser`, `moisturizer`, `spf`) niski stan i świeże użycie mają większe znaczenie niż dla produktów okazjonalnych
6. Dla produktów okazjonalnych (`exfoliant`, `mask`, `spot_treatment`) świeżość użycia jest ważniejsza niż sam niski stan
7. Jeśli `sealed_backup_count` > 0, zwykle nie rekomenduj odkupu
8. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
9. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
10. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
11. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
12. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
13. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada
14. Odpowiadaj w języku polskim
15. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
16. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
17. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić teraz, jak wpisuje się w obecną rutynę i jakie ograniczenia
18. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
19. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
20. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
21. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
22. `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"
- target_concerns: "acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" | "redness" | "damaged_barrier" | "pore_visibility" | "uneven_texture" | "hair_growth" | "sebum_excess"
- recommended_time: "am" | "pm" | "both" - recommended_time: "am" | "pm" | "both"
Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""

View file

@ -267,6 +267,26 @@ def test_suggest_shopping_invalid_schema_returns_502(client):
assert "suggestions/0/priority" in r.json()["detail"] assert "suggestions/0/priority" in r.json()["detail"]
def test_suggest_shopping_invalid_target_concern_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": "high", "key_ingredients": ["glycerin"], "target_concerns": ["inflammation"], "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.", "fit_with_current_routine": "To domknie podstawowy krok cleanse bez dokladania agresywnych aktywow.", "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/target_concerns/0" 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(),