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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue