diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index eab246d..7391a42 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -416,7 +416,7 @@ class ProductSuggestion(PydanticBase): product_type: str priority: Literal["high", "medium", "low"] key_ingredients: list[str] - target_concerns: list[str] + target_concerns: list[SkinConcern] recommended_time: DayTime frequency: str short_reason: str @@ -440,7 +440,7 @@ class _ProductSuggestionOut(PydanticBase): product_type: str priority: Literal["high", "medium", "low"] key_ingredients: list[str] - target_concerns: list[str] + target_concerns: list[SkinConcern] recommended_time: DayTime frequency: 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. -Twoim zadaniem jest ocenić dwie rzeczy: -1. czy w rutynie użytkownika istnieją realne luki produktowe, -2. czy któryś z już posiadanych typów produktów warto odkupić teraz z powodu kończącego się zapasu. - -Masz działać konserwatywnie: nie sugeruj zakupu tylko dlatego, że coś mogłoby się przydać. -Sugestia ma pojawić się tylko wtedy, gdy istnieje wyraźny powód praktyczny. - -WAŻNE POJĘCIA: -- `stock_state` opisuje stan zapasu: `out_of_stock`, `healthy`, `monitor`, `low`, `urgent` -- `sealed_backup_count` > 0 zwykle oznacza, że odkup nie jest pilny -- `lowest_remaining_level` dotyczy tylko otwartych opakowań i może mieć wartość: `high`, `medium`, `low`, `nearly_empty` -- `last_used_on` i `days_since_last_used` pomagają ocenić, czy produkt jest nadal faktycznie używany -- `replenishment_score`, `replenishment_priority_hint` i `repurchase_candidate` są backendowym sygnałem pilności odkupu; traktuj je jako mocną wskazówkę -- `reason_codes` tłumaczą, dlaczego backend uznał odkup za pilny albo niepilny - -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ć ją teraz, jak wpisuje się w obecną rutynę i jakie są 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 +Oceń dwie rzeczy: realne luki w rutynie oraz odkupy produktów, które warto uzupełnić teraz. +Działaj konserwatywnie: sugeruj tylko wtedy, gdy istnieje wyraźny powód praktyczny. +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. +`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. +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. +`product_type` ma być krótką nazwą typu produktu i opisywać funkcję produktu, a nie opis marketingowy lub pełną specyfikację składu. +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. +Uwzględniaj aktywne problemy skóry, miejsce produktu w rutynie, konflikty składników i bezpieczeństwo przy naruszonej barierze. +Pisz po polsku, językiem praktycznym i zakupowym. Unikaj nadmiernie medycznego lub diagnostycznego tonu. +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. +Możesz zwrócić pustą listę `suggestions`, jeśli nie widzisz realnej potrzeby. +`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. DOZWOLONE WARTOŚCI ENUMÓW: - 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" Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 4693283..90f1c5f 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -267,6 +267,26 @@ def test_suggest_shopping_invalid_schema_returns_502(client): 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): p = Product( id=uuid.uuid4(),