Spaces:
Sleeping
Sleeping
rag
Browse files- Dockerfile +16 -0
- add_prices.py +51 -0
- app.py +68 -0
- data/amazon_skincare_cleaned.csv +0 -0
- data/dermstore_skincare_cleaned.csv +0 -0
- data/lookfantastic_skincare_cleaned.csv +0 -0
- french_brands.py +65 -0
- ingest.py +236 -0
- requirements.txt +7 -0
- retriever.py +323 -0
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir --timeout=200 --retries=5 -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY app.py .
|
| 9 |
+
COPY ingest.py .
|
| 10 |
+
COPY retriever.py .
|
| 11 |
+
COPY french_brands.py .
|
| 12 |
+
COPY data ./data
|
| 13 |
+
|
| 14 |
+
EXPOSE 8000
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
add_prices.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
RAW_DIR = Path(__file__).parent.parent / "data" / "raw" / "skincare_products"
|
| 6 |
+
CLEAN_DIR = Path(__file__).parent / "data"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def add_prices():
|
| 10 |
+
# --- Dermstore: join on title to recover price + currency ---
|
| 11 |
+
clean = pd.read_csv(CLEAN_DIR / "dermstore_skincare_cleaned.csv")
|
| 12 |
+
if "price" not in clean.columns:
|
| 13 |
+
raw = pd.read_csv(RAW_DIR / "dermstore_data.csv", usecols=["title", "price", "currency"])
|
| 14 |
+
raw["title"] = raw["title"].str.strip()
|
| 15 |
+
raw = raw.drop_duplicates("title")
|
| 16 |
+
clean["title"] = clean["title"].str.strip()
|
| 17 |
+
clean = clean.merge(raw, on="title", how="left")
|
| 18 |
+
clean.to_csv(CLEAN_DIR / "dermstore_skincare_cleaned.csv", index=False)
|
| 19 |
+
print(f"Dermstore: {clean['price'].notna().sum()}/{len(clean)} produits avec prix")
|
| 20 |
+
else:
|
| 21 |
+
print("Dermstore: colonne price déjà présente, skip")
|
| 22 |
+
|
| 23 |
+
# --- LookFantastic: price existe mais format '£5.20', normaliser ---
|
| 24 |
+
clean = pd.read_csv(CLEAN_DIR / "lookfantastic_skincare_cleaned.csv")
|
| 25 |
+
if "currency" not in clean.columns:
|
| 26 |
+
clean["price"] = (
|
| 27 |
+
clean["price"]
|
| 28 |
+
.astype(str)
|
| 29 |
+
.str.replace("£", "", regex=False)
|
| 30 |
+
.str.strip()
|
| 31 |
+
)
|
| 32 |
+
clean["price"] = pd.to_numeric(clean["price"], errors="coerce")
|
| 33 |
+
clean["currency"] = "GBP"
|
| 34 |
+
clean.to_csv(CLEAN_DIR / "lookfantastic_skincare_cleaned.csv", index=False)
|
| 35 |
+
print(f"LookFantastic: {clean['price'].notna().sum()}/{len(clean)} produits avec prix")
|
| 36 |
+
else:
|
| 37 |
+
print("LookFantastic: colonne currency déjà présente, skip")
|
| 38 |
+
|
| 39 |
+
# --- Amazon: prix en INR, non fiables → null ---
|
| 40 |
+
clean = pd.read_csv(CLEAN_DIR / "amazon_skincare_cleaned.csv")
|
| 41 |
+
if "price" not in clean.columns:
|
| 42 |
+
clean["price"] = None
|
| 43 |
+
clean["currency"] = None
|
| 44 |
+
clean.to_csv(CLEAN_DIR / "amazon_skincare_cleaned.csv", index=False)
|
| 45 |
+
print(f"Amazon: {len(clean)} produits sans prix (INR source, non exploitable)")
|
| 46 |
+
else:
|
| 47 |
+
print("Amazon: colonne price déjà présente, skip")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
add_prices()
|
app.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
import uvicorn
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from ingest import CHROMA_DIR, build_index
|
| 10 |
+
from retriever import recommend
|
| 11 |
+
|
| 12 |
+
VALID_FORMULATIONS = {"vegan", "clean", "bio"}
|
| 13 |
+
VALID_ORIGINS = {"french"}
|
| 14 |
+
VALID_PRODUCT_TYPES = {
|
| 15 |
+
"moisturizer", "serum", "cleanser", "toner",
|
| 16 |
+
"mask", "oil", "sunscreen", "exfoliant", "eye_care",
|
| 17 |
+
}
|
| 18 |
+
VALID_PRICE_BANDS = {"budget", "mid", "premium", "any"}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@asynccontextmanager
|
| 22 |
+
async def lifespan(app: FastAPI):
|
| 23 |
+
if not CHROMA_DIR.exists() or not any(CHROMA_DIR.iterdir()):
|
| 24 |
+
build_index()
|
| 25 |
+
yield
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title="Product Recommendation API", lifespan=lifespan)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Preferences(BaseModel):
|
| 32 |
+
formulation: List[str] = [] # ex: ["vegan", "clean"]
|
| 33 |
+
origin: List[str] = [] # ex: ["french"]
|
| 34 |
+
product_type: Optional[str] = None # ex: "serum"
|
| 35 |
+
price_band: Optional[str] = None # "budget" | "mid" | "premium" | "any"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class RecommendationInput(BaseModel):
|
| 39 |
+
skin_type: str # "oily" | "dry" | "normal"
|
| 40 |
+
acne: bool
|
| 41 |
+
preferences: Preferences
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.get("/health")
|
| 45 |
+
def health():
|
| 46 |
+
return {"status": "ok"}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.post("/recommend")
|
| 50 |
+
def recommend_endpoint(payload: RecommendationInput):
|
| 51 |
+
prefs = payload.preferences.model_dump()
|
| 52 |
+
|
| 53 |
+
prefs["formulation"] = [f for f in prefs["formulation"] if f in VALID_FORMULATIONS]
|
| 54 |
+
prefs["origin"] = [o for o in prefs["origin"] if o in VALID_ORIGINS]
|
| 55 |
+
if prefs.get("product_type") not in VALID_PRODUCT_TYPES:
|
| 56 |
+
prefs["product_type"] = None
|
| 57 |
+
if prefs.get("price_band") not in VALID_PRICE_BANDS:
|
| 58 |
+
prefs["price_band"] = None
|
| 59 |
+
|
| 60 |
+
return recommend(
|
| 61 |
+
skin_type=payload.skin_type,
|
| 62 |
+
acne=payload.acne,
|
| 63 |
+
preferences=prefs,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False)
|
data/amazon_skincare_cleaned.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/dermstore_skincare_cleaned.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/lookfantastic_skincare_cleaned.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
french_brands.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FRENCH_BRANDS = {
|
| 2 |
+
"vichy",
|
| 3 |
+
"la roche-posay",
|
| 4 |
+
"la roche posay",
|
| 5 |
+
"bioderma",
|
| 6 |
+
"avène",
|
| 7 |
+
"avene",
|
| 8 |
+
"eau thermale avène",
|
| 9 |
+
"eau thermale avene",
|
| 10 |
+
"caudalie",
|
| 11 |
+
"nuxe",
|
| 12 |
+
"clarins",
|
| 13 |
+
"lancôme",
|
| 14 |
+
"lancome",
|
| 15 |
+
"l'oréal",
|
| 16 |
+
"l'oreal",
|
| 17 |
+
"loreal",
|
| 18 |
+
"garnier",
|
| 19 |
+
"yves rocher",
|
| 20 |
+
"yves saint laurent",
|
| 21 |
+
"ysl beauté",
|
| 22 |
+
"institut esthederm",
|
| 23 |
+
"esthederm",
|
| 24 |
+
"sothys",
|
| 25 |
+
"filorga",
|
| 26 |
+
"embryolisse",
|
| 27 |
+
"lierac",
|
| 28 |
+
"liérac",
|
| 29 |
+
"roc",
|
| 30 |
+
"roger & gallet",
|
| 31 |
+
"sisley",
|
| 32 |
+
"givenchy",
|
| 33 |
+
"christian dior",
|
| 34 |
+
"dior",
|
| 35 |
+
"chanel",
|
| 36 |
+
"guerlain",
|
| 37 |
+
"payot",
|
| 38 |
+
"biotherm",
|
| 39 |
+
"mustela",
|
| 40 |
+
"galénic",
|
| 41 |
+
"galenic",
|
| 42 |
+
"phyto",
|
| 43 |
+
"kérastase",
|
| 44 |
+
"kerastase",
|
| 45 |
+
"decléor",
|
| 46 |
+
"decleor",
|
| 47 |
+
"svr",
|
| 48 |
+
"a-derma",
|
| 49 |
+
"aderma",
|
| 50 |
+
"ducray",
|
| 51 |
+
"klorane",
|
| 52 |
+
"rené furterer",
|
| 53 |
+
"rene furterer",
|
| 54 |
+
"nuxuriance",
|
| 55 |
+
"by terry",
|
| 56 |
+
"make up for ever",
|
| 57 |
+
"sephora collection",
|
| 58 |
+
"erborian",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_french(brand: str) -> bool:
|
| 63 |
+
if not brand or not isinstance(brand, str):
|
| 64 |
+
return False
|
| 65 |
+
return brand.lower().strip() in FRENCH_BRANDS
|
ingest.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import chromadb
|
| 5 |
+
from sentence_transformers import SentenceTransformer
|
| 6 |
+
|
| 7 |
+
from french_brands import is_french
|
| 8 |
+
|
| 9 |
+
CLEAN_DIR = Path(__file__).parent / "data"
|
| 10 |
+
CHROMA_DIR = Path(__file__).parent / "chroma_db"
|
| 11 |
+
COLLECTION_NAME = "skincare_products"
|
| 12 |
+
|
| 13 |
+
USD_TO_EUR = 0.92
|
| 14 |
+
GBP_TO_EUR = 1.17
|
| 15 |
+
|
| 16 |
+
# Product type detection — ordered by specificity (most specific first)
|
| 17 |
+
PRODUCT_TYPE_KEYWORDS = [
|
| 18 |
+
("sunscreen", ["spf", "sunscreen", "sun protection", "solaire", "solar"]),
|
| 19 |
+
("eye_care", ["eye cream", "eye serum", "eye gel", "contour des yeux", "eye care"]),
|
| 20 |
+
("serum", ["serum", "sérum", "essence", "ampoule", "concentrate"]),
|
| 21 |
+
("oil", ["face oil", "dry oil", "huile visage", "facial oil"]),
|
| 22 |
+
("mask", ["mask", "masque", "sheet mask"]),
|
| 23 |
+
("exfoliant", ["exfoliant", "scrub", "peel", "exfoliating", " aha ", " bha ", "acid toner"]),
|
| 24 |
+
("cleanser", ["cleanser", "cleansing", "face wash", "foaming", "micellar", "gel nettoyant", "mousse nettoyante"]),
|
| 25 |
+
("toner", ["toner", "toning lotion", "lotion tonique", "mist", "facial mist"]),
|
| 26 |
+
("moisturizer", ["moisturizer", "moisturiser", "cream", "crème", "lotion", "balm", "baume", "gel moistur"]),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
NON_VEGAN = [
|
| 30 |
+
"beeswax", "cera alba", "lanolin", "carmine", "collagen", "elastin",
|
| 31 |
+
"gelatin", "gelatine", "honey", "mel ", "silk", "sericin",
|
| 32 |
+
"squalene", "tallow", "lard", "keratin", "casein", "chitosan",
|
| 33 |
+
"guanine", "shellac",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
NON_CLEAN = [
|
| 37 |
+
"paraben", "sodium lauryl sulfate", "sodium laureth sulfate",
|
| 38 |
+
"dimethicone", "cyclopentasiloxane", "cyclohexasiloxane", "cyclomethicone",
|
| 39 |
+
"phthalate", "formaldehyde", "petrolatum", "mineral oil",
|
| 40 |
+
"polyethylene glycol", "peg-", "butylated hydroxytoluene", "bht",
|
| 41 |
+
"butylated hydroxyanisole", "bha ",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
BIO_KEYWORDS = [
|
| 45 |
+
"organic", "bio ", "ecocert", "cosmos organic", "certifié bio",
|
| 46 |
+
"certified organic", "usda organic",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _to_eur(price, currency: str) -> float | None:
|
| 51 |
+
try:
|
| 52 |
+
price = float(price)
|
| 53 |
+
except (TypeError, ValueError):
|
| 54 |
+
return None
|
| 55 |
+
if currency == "USD":
|
| 56 |
+
return round(price * USD_TO_EUR, 2)
|
| 57 |
+
if currency == "GBP":
|
| 58 |
+
return round(price * GBP_TO_EUR, 2)
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _price_band(price_eur: float | None) -> str:
|
| 63 |
+
if price_eur is None:
|
| 64 |
+
return "unknown"
|
| 65 |
+
if price_eur < 15:
|
| 66 |
+
return "budget"
|
| 67 |
+
if price_eur <= 50:
|
| 68 |
+
return "mid"
|
| 69 |
+
return "premium"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _normalize_type(raw_type: str, title: str = "") -> str:
|
| 73 |
+
text = f"{raw_type or ''} {title or ''}".lower()
|
| 74 |
+
for product_type, keywords in PRODUCT_TYPE_KEYWORDS:
|
| 75 |
+
if any(kw in text for kw in keywords):
|
| 76 |
+
return product_type
|
| 77 |
+
return "other"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _detect_vegan(ingredients: str, description: str = "") -> bool:
|
| 81 |
+
combined = f"{ingredients or ''} {description or ''}".lower()
|
| 82 |
+
if "vegan" in combined:
|
| 83 |
+
return True
|
| 84 |
+
if not ingredients:
|
| 85 |
+
return False
|
| 86 |
+
return not any(nv in ingredients.lower() for nv in NON_VEGAN)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _detect_clean(ingredients: str) -> bool:
|
| 90 |
+
if not ingredients:
|
| 91 |
+
return False
|
| 92 |
+
ingr = ingredients.lower()
|
| 93 |
+
return not any(nc in ingr for nc in NON_CLEAN)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _detect_bio(ingredients: str, description: str = "", title: str = "") -> bool:
|
| 97 |
+
text = f"{ingredients or ''} {description or ''} {title or ''}".lower()
|
| 98 |
+
return any(bk in text for bk in BIO_KEYWORDS)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _make_document(r: dict) -> str:
|
| 102 |
+
parts = []
|
| 103 |
+
type_label = r["product_type"].replace("_", " ")
|
| 104 |
+
parts.append(f"{type_label.title()} par {r['brand'] or 'marque inconnue'} : {r['name']}.")
|
| 105 |
+
if r["skin_type"]:
|
| 106 |
+
parts.append(f"Convient pour : {r['skin_type'][:120]}.")
|
| 107 |
+
if r["description"]:
|
| 108 |
+
parts.append(r["description"][:300])
|
| 109 |
+
if r["ingredients"]:
|
| 110 |
+
parts.append(f"Ingrédients : {r['ingredients'][:300]}.")
|
| 111 |
+
flags = []
|
| 112 |
+
if r["is_vegan"]:
|
| 113 |
+
flags.append("vegan")
|
| 114 |
+
if r["is_clean"]:
|
| 115 |
+
flags.append("clean")
|
| 116 |
+
if r["is_bio"]:
|
| 117 |
+
flags.append("bio")
|
| 118 |
+
if r["is_french"]:
|
| 119 |
+
flags.append("marque française")
|
| 120 |
+
if flags:
|
| 121 |
+
parts.append(f"Labels : {', '.join(flags)}.")
|
| 122 |
+
if r["price_eur"] is not None:
|
| 123 |
+
parts.append(f"Prix : {r['price_eur']}€.")
|
| 124 |
+
return " ".join(parts)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _load_amazon() -> list[dict]:
|
| 128 |
+
df = pd.read_csv(CLEAN_DIR / "amazon_skincare_cleaned.csv")
|
| 129 |
+
records = []
|
| 130 |
+
for _, row in df.iterrows():
|
| 131 |
+
records.append({
|
| 132 |
+
"name": str(row["Title"]).strip(),
|
| 133 |
+
"brand": str(row["Brand"]).strip(),
|
| 134 |
+
"product_type": _normalize_type(str(row["Product"]), str(row["Title"])),
|
| 135 |
+
"description": "",
|
| 136 |
+
"ingredients": "",
|
| 137 |
+
"skin_type": str(row.get("Skin_Type", "")).lower().strip(),
|
| 138 |
+
"price_eur": None,
|
| 139 |
+
"source": "amazon",
|
| 140 |
+
})
|
| 141 |
+
return records
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _load_dermstore() -> list[dict]:
|
| 145 |
+
df = pd.read_csv(CLEAN_DIR / "dermstore_skincare_cleaned.csv")
|
| 146 |
+
records = []
|
| 147 |
+
for _, row in df.iterrows():
|
| 148 |
+
price_eur = _to_eur(row.get("price"), str(row.get("currency", "USD")))
|
| 149 |
+
records.append({
|
| 150 |
+
"name": str(row["title"]).strip(),
|
| 151 |
+
"brand": str(row["brand"]).strip(),
|
| 152 |
+
"product_type": _normalize_type("", str(row["title"])),
|
| 153 |
+
"description": str(row.get("description", ""))[:500],
|
| 154 |
+
"ingredients": str(row.get("ingredients", "")),
|
| 155 |
+
"skin_type": str(row.get("skin_type_and_concerns", "")).lower()[:200],
|
| 156 |
+
"price_eur": price_eur,
|
| 157 |
+
"source": "dermstore",
|
| 158 |
+
})
|
| 159 |
+
return records
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _load_lookfantastic() -> list[dict]:
|
| 163 |
+
df = pd.read_csv(CLEAN_DIR / "lookfantastic_skincare_cleaned.csv")
|
| 164 |
+
records = []
|
| 165 |
+
for _, row in df.iterrows():
|
| 166 |
+
price_eur = _to_eur(row.get("price"), "GBP")
|
| 167 |
+
records.append({
|
| 168 |
+
"name": str(row["product_name"]).strip(),
|
| 169 |
+
"brand": "",
|
| 170 |
+
"product_type": _normalize_type(str(row.get("product_type", "")), str(row["product_name"])),
|
| 171 |
+
"description": "",
|
| 172 |
+
"ingredients": str(row.get("ingredients", "")),
|
| 173 |
+
"skin_type": "",
|
| 174 |
+
"price_eur": price_eur,
|
| 175 |
+
"source": "lookfantastic",
|
| 176 |
+
})
|
| 177 |
+
return records
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def build_index():
|
| 181 |
+
print("Chargement des produits...")
|
| 182 |
+
records = _load_amazon() + _load_dermstore() + _load_lookfantastic()
|
| 183 |
+
print(f"{len(records)} produits chargés")
|
| 184 |
+
|
| 185 |
+
for r in records:
|
| 186 |
+
r["is_vegan"] = _detect_vegan(r["ingredients"], r["description"])
|
| 187 |
+
r["is_clean"] = _detect_clean(r["ingredients"])
|
| 188 |
+
r["is_bio"] = _detect_bio(r["ingredients"], r["description"], r["name"])
|
| 189 |
+
r["is_french"] = is_french(r["brand"])
|
| 190 |
+
r["price_band"] = _price_band(r["price_eur"])
|
| 191 |
+
|
| 192 |
+
documents = [_make_document(r) for r in records]
|
| 193 |
+
ids = [f"product_{i}" for i in range(len(records))]
|
| 194 |
+
|
| 195 |
+
metadatas = [
|
| 196 |
+
{
|
| 197 |
+
"name": r["name"],
|
| 198 |
+
"brand": r["brand"] or "",
|
| 199 |
+
"product_type": r["product_type"],
|
| 200 |
+
"price_eur": float(r["price_eur"]) if r["price_eur"] is not None else -1.0,
|
| 201 |
+
"price_band": r["price_band"],
|
| 202 |
+
"source": r["source"],
|
| 203 |
+
"is_vegan": r["is_vegan"],
|
| 204 |
+
"is_clean": r["is_clean"],
|
| 205 |
+
"is_bio": r["is_bio"],
|
| 206 |
+
"is_french": r["is_french"],
|
| 207 |
+
}
|
| 208 |
+
for r in records
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
print("Génération des embeddings...", flush=True)
|
| 212 |
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 213 |
+
embeddings = model.encode(documents, show_progress_bar=True).tolist()
|
| 214 |
+
|
| 215 |
+
print("Stockage dans ChromaDB...")
|
| 216 |
+
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
| 217 |
+
try:
|
| 218 |
+
client.delete_collection(COLLECTION_NAME)
|
| 219 |
+
except Exception:
|
| 220 |
+
pass
|
| 221 |
+
collection = client.create_collection(COLLECTION_NAME)
|
| 222 |
+
|
| 223 |
+
batch_size = 500
|
| 224 |
+
for i in range(0, len(records), batch_size):
|
| 225 |
+
collection.add(
|
| 226 |
+
documents=documents[i : i + batch_size],
|
| 227 |
+
embeddings=embeddings[i : i + batch_size],
|
| 228 |
+
metadatas=metadatas[i : i + batch_size],
|
| 229 |
+
ids=ids[i : i + batch_size],
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
print(f"Index construit : {len(records)} produits indexés dans {CHROMA_DIR}")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
build_index()
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
pandas
|
| 4 |
+
pydantic
|
| 5 |
+
sentence-transformers
|
| 6 |
+
chromadb
|
| 7 |
+
groq
|
retriever.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
import chromadb
|
| 6 |
+
from groq import Groq
|
| 7 |
+
from sentence_transformers import SentenceTransformer
|
| 8 |
+
|
| 9 |
+
CHROMA_DIR = Path(__file__).parent / "chroma_db"
|
| 10 |
+
COLLECTION_NAME = "skincare_products"
|
| 11 |
+
|
| 12 |
+
_model: Optional[SentenceTransformer] = None
|
| 13 |
+
_collection = None
|
| 14 |
+
|
| 15 |
+
ROUTINE_STEPS = {
|
| 16 |
+
"matin": ["cleanser", "toner", "serum", "moisturizer"],
|
| 17 |
+
"soir": ["cleanser", "serum", "moisturizer"],
|
| 18 |
+
}
|
| 19 |
+
SKIN_RULES = {
|
| 20 |
+
"oily": {
|
| 21 |
+
"avoid": ["oil", "heavy", "comedogenic"],
|
| 22 |
+
"prefer": ["oil-free", "non-comedogenic", "light", "gel"],
|
| 23 |
+
},
|
| 24 |
+
"dry": {
|
| 25 |
+
"avoid": ["alcohol"],
|
| 26 |
+
"prefer": ["hydrating", "ceramides", "rich"],
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
def _get_model() -> SentenceTransformer:
|
| 31 |
+
global _model
|
| 32 |
+
if _model is None:
|
| 33 |
+
_model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 34 |
+
return _model
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _get_collection():
|
| 38 |
+
global _collection
|
| 39 |
+
if _collection is None:
|
| 40 |
+
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
| 41 |
+
_collection = client.get_collection(COLLECTION_NAME)
|
| 42 |
+
return _collection
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _build_query(skin_type: str, acne: bool, preferences: dict) -> str:
|
| 46 |
+
parts = [f"skincare product for {skin_type} skin"]
|
| 47 |
+
if acne:
|
| 48 |
+
parts.append("acne-prone")
|
| 49 |
+
if pt := preferences.get("product_type"):
|
| 50 |
+
parts.append(pt)
|
| 51 |
+
for f in preferences.get("formulation", []):
|
| 52 |
+
parts.append(f)
|
| 53 |
+
if "french" in preferences.get("origin", []):
|
| 54 |
+
parts.append("French brand")
|
| 55 |
+
return ", ".join(parts)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _build_where(preferences: dict) -> Optional[dict]:
|
| 59 |
+
conditions = []
|
| 60 |
+
for flag, key in [("vegan", "is_vegan"), ("clean", "is_clean"), ("bio", "is_bio")]:
|
| 61 |
+
if flag in preferences.get("formulation", []):
|
| 62 |
+
conditions.append({key: {"$eq": True}})
|
| 63 |
+
if "french" in preferences.get("origin", []):
|
| 64 |
+
conditions.append({"is_french": {"$eq": True}})
|
| 65 |
+
if (pb := preferences.get("price_band")) and pb not in ("any", None):
|
| 66 |
+
conditions.append({"price_band": {"$eq": pb}})
|
| 67 |
+
if not conditions:
|
| 68 |
+
return None
|
| 69 |
+
if len(conditions) == 1:
|
| 70 |
+
return conditions[0]
|
| 71 |
+
return {"$and": conditions}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _query(query_text: str, where: Optional[dict], n_results: int = 5) -> dict:
|
| 75 |
+
collection = _get_collection()
|
| 76 |
+
model = _get_model() # ← ton SentenceTransformer, enfin utilisé
|
| 77 |
+
|
| 78 |
+
embedding = model.encode(query_text).tolist() # même modèle qu'ingest.py
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
kwargs: dict = {
|
| 82 |
+
"query_embeddings": [embedding], # ← vecteur, plus query_texts
|
| 83 |
+
"n_results": n_results,
|
| 84 |
+
}
|
| 85 |
+
if where:
|
| 86 |
+
kwargs["where"] = where
|
| 87 |
+
return collection.query(**kwargs)
|
| 88 |
+
except Exception:
|
| 89 |
+
return {"ids": [[]], "documents": [[]], "metadatas": [[]]}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _generate_explanation(product_doc: str, skin_type: str, acne: bool, preferences: dict) -> str:
|
| 93 |
+
prefs = []
|
| 94 |
+
for f in preferences.get("formulation", []):
|
| 95 |
+
prefs.append(f)
|
| 96 |
+
if "french" in preferences.get("origin", []):
|
| 97 |
+
prefs.append("marque française")
|
| 98 |
+
if pt := preferences.get("product_type"):
|
| 99 |
+
prefs.append(f"type : {pt}")
|
| 100 |
+
if pb := preferences.get("price_band"):
|
| 101 |
+
prefs.append(f"budget : {pb}")
|
| 102 |
+
|
| 103 |
+
profile = f"peau {skin_type}"
|
| 104 |
+
if acne:
|
| 105 |
+
profile += ", tendance acnéique"
|
| 106 |
+
if prefs:
|
| 107 |
+
profile += f", préférences : {', '.join(prefs)}"
|
| 108 |
+
|
| 109 |
+
client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 110 |
+
response = client.chat.completions.create(
|
| 111 |
+
model="llama-3.3-70b-versatile",
|
| 112 |
+
messages=[
|
| 113 |
+
{
|
| 114 |
+
"role": "system",
|
| 115 |
+
"content": (
|
| 116 |
+
"Tu es un expert en soins de la peau. "
|
| 117 |
+
"En 2 à 3 phrases courtes en français, explique pourquoi ce produit "
|
| 118 |
+
"est adapté au profil de l'utilisateur. Sois précis et bienveillant. "
|
| 119 |
+
),
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"role": "user",
|
| 123 |
+
"content": f"Profil : {profile}\n\nProduit : {product_doc}",
|
| 124 |
+
},
|
| 125 |
+
],
|
| 126 |
+
max_tokens=350,
|
| 127 |
+
)
|
| 128 |
+
return response.choices[0].message.content
|
| 129 |
+
|
| 130 |
+
def routine_with_llm(routine: dict, skin_type: str, acne: bool) -> str:
|
| 131 |
+
import os
|
| 132 |
+
from groq import Groq
|
| 133 |
+
|
| 134 |
+
client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 135 |
+
|
| 136 |
+
# 1. FIX: On boucle sur la 'routine' envoyée, pas sur le dictionnaire brut ROUTINE_STEPS
|
| 137 |
+
routine_text = ""
|
| 138 |
+
for moment, steps in routine.items():
|
| 139 |
+
routine_text += f"\n{moment.upper()}:\n"
|
| 140 |
+
for step in steps:
|
| 141 |
+
if step["product_name"]:
|
| 142 |
+
routine_text += f"- {step['etape']}: {step['product_name']} ({step['brand']})\n"
|
| 143 |
+
else:
|
| 144 |
+
routine_text += f"- {step['etape']}: Aucun produit\n"
|
| 145 |
+
|
| 146 |
+
profile = f"peau {skin_type}"
|
| 147 |
+
if acne:
|
| 148 |
+
profile += ", tendance acnéique"
|
| 149 |
+
|
| 150 |
+
response = client.chat.completions.create(
|
| 151 |
+
model="llama-3.3-70b-versatile",
|
| 152 |
+
messages=[
|
| 153 |
+
{
|
| 154 |
+
"role": "system",
|
| 155 |
+
"content": (
|
| 156 |
+
"Tu es un influenceur beauté expert en skincare. "
|
| 157 |
+
"Propose ta routine skincare en suivant la routine skincare qui t'a été fournie. "
|
| 158 |
+
"1. Verifie que le produit recommandé est adapté et si c'est le cas propose le dans ta routine"
|
| 159 |
+
"2. Propose un produit adapte au resultat du type de peau. "
|
| 160 |
+
"3. Explique avec enthousiasme et brievement le rôle de chaque produit. "
|
| 161 |
+
"Réponds en français avec un ton chaleureux. "
|
| 162 |
+
"CRUCIAL : Tu dois ABSOLUMENT utiliser la syntaxe Markdown suivante pour structurer ta réponse.\n\n"
|
| 163 |
+
"Format OBLIGATOIRE:\n"
|
| 164 |
+
"Coucou ! Voici ta routine sur-mesure ✨ :\n\n"
|
| 165 |
+
"### 🌞 Matin\n"
|
| 166 |
+
"- **[product_type]** :[Nom du produit] [Ton explication...]\n\n"
|
| 167 |
+
"### 🌙 Soir\n"
|
| 168 |
+
"- **[product_type]** : [Nom du produit][Ton explication...]\n"
|
| 169 |
+
"J'espère que cette routine te conviendra 😎!"
|
| 170 |
+
),
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"role": "user",
|
| 174 |
+
"content": f"Profil: {profile}\n\nRoutine exacte à présenter:\n{routine_text}",
|
| 175 |
+
},
|
| 176 |
+
],
|
| 177 |
+
max_tokens=550,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
return response.choices[0].message.content
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def is_compatible(meta: dict, skin_type: str) -> bool:
|
| 184 |
+
"""Vérifie que le produit ne contient pas d'ingrédients déconseillés."""
|
| 185 |
+
rules = SKIN_RULES.get(skin_type, {})
|
| 186 |
+
name = meta.get("name", "").lower()
|
| 187 |
+
return not any(word in name for word in rules.get("avoid", []))
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# 2. FIX: Il FAUT garder cette fonction dé-commentée, c'est elle qui interroge ChromaDB !
|
| 191 |
+
def _build_routine_steps(
|
| 192 |
+
skin_type: str,
|
| 193 |
+
acne: bool,
|
| 194 |
+
main_product: dict,
|
| 195 |
+
preferences: dict
|
| 196 |
+
) -> dict:
|
| 197 |
+
|
| 198 |
+
routine = {}
|
| 199 |
+
|
| 200 |
+
for moment, steps in ROUTINE_STEPS.items():
|
| 201 |
+
routine[moment] = []
|
| 202 |
+
|
| 203 |
+
for step in steps:
|
| 204 |
+
# Produit principal = on prend direct
|
| 205 |
+
if main_product.get("product_type") == step:
|
| 206 |
+
routine[moment].append({
|
| 207 |
+
"etape": step,
|
| 208 |
+
"product_name": main_product["product_name"],
|
| 209 |
+
"brand": main_product["brand"],
|
| 210 |
+
"price_display": main_product["price_display"],
|
| 211 |
+
"is_vegan": main_product["is_vegan"],
|
| 212 |
+
"is_clean": main_product["is_clean"],
|
| 213 |
+
"is_main": True,
|
| 214 |
+
})
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
# Sinon -> chercher un produit
|
| 218 |
+
query = f"{step} pour peau {skin_type}"
|
| 219 |
+
if acne and step in ("serum", "cleanser", "exfoliant"):
|
| 220 |
+
query += " acnéique"
|
| 221 |
+
|
| 222 |
+
if skin_type == "oily" and step == "serum":
|
| 223 |
+
query += " oil-free serum gel"
|
| 224 |
+
if skin_type == "oily" and step == "moisturizer":
|
| 225 |
+
query += " lightweight oil-free gel moisturizer"
|
| 226 |
+
if step == "toner":
|
| 227 |
+
query += " toner astringent niacinamide"
|
| 228 |
+
if step == "exfoliant":
|
| 229 |
+
if acne:
|
| 230 |
+
query += " salicylic acid bha exfoliant"
|
| 231 |
+
|
| 232 |
+
where = {"product_type": {"$eq": step}}
|
| 233 |
+
|
| 234 |
+
results = _query(query, where, n_results=5)
|
| 235 |
+
|
| 236 |
+
if results["ids"][0]:
|
| 237 |
+
candidates = results["metadatas"][0]
|
| 238 |
+
chosen = None
|
| 239 |
+
for m in candidates:
|
| 240 |
+
if is_compatible(m, skin_type):
|
| 241 |
+
chosen = m
|
| 242 |
+
break
|
| 243 |
+
|
| 244 |
+
if not chosen:
|
| 245 |
+
chosen = candidates[0]
|
| 246 |
+
|
| 247 |
+
routine[moment].append({
|
| 248 |
+
"etape": step,
|
| 249 |
+
"product_name": chosen["name"],
|
| 250 |
+
"brand": chosen["brand"],
|
| 251 |
+
"price_display": f"{chosen['price_eur']}€" if chosen["price_eur"] > 0 else "",
|
| 252 |
+
"is_vegan": chosen["is_vegan"],
|
| 253 |
+
"is_clean": chosen["is_clean"],
|
| 254 |
+
"is_main": False,
|
| 255 |
+
})
|
| 256 |
+
else:
|
| 257 |
+
routine[moment].append({
|
| 258 |
+
"etape": step,
|
| 259 |
+
"product_name": None,
|
| 260 |
+
"brand": None,
|
| 261 |
+
"is_main": False,
|
| 262 |
+
})
|
| 263 |
+
|
| 264 |
+
return routine
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def recommend(skin_type: str, acne: bool, preferences: dict) -> dict:
|
| 268 |
+
query_text = _build_query(skin_type, acne, preferences)
|
| 269 |
+
where = _build_where(preferences)
|
| 270 |
+
|
| 271 |
+
results = _query(query_text, where, n_results=5)
|
| 272 |
+
|
| 273 |
+
if not results["ids"][0] and where:
|
| 274 |
+
results = _query(query_text, None, n_results=5)
|
| 275 |
+
|
| 276 |
+
if not results["ids"][0]:
|
| 277 |
+
results = _query(query_text, None, n_results=5)
|
| 278 |
+
|
| 279 |
+
if not results["ids"][0]:
|
| 280 |
+
return {"error": "Aucun produit trouvé"}
|
| 281 |
+
|
| 282 |
+
meta = results["metadatas"][0][0]
|
| 283 |
+
doc = results["documents"][0][0]
|
| 284 |
+
|
| 285 |
+
main_product_dict = {
|
| 286 |
+
"product_name": meta["name"],
|
| 287 |
+
"brand": meta["brand"],
|
| 288 |
+
"product_type": meta["product_type"],
|
| 289 |
+
"price_display": f"{meta['price_eur']}€" if meta["price_eur"] > 0 else "",
|
| 290 |
+
"is_vegan": meta["is_vegan"],
|
| 291 |
+
"is_clean": meta["is_clean"],
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
# 3. FIX: On dé-commente la création de la routine !
|
| 295 |
+
routine_complete = _build_routine_steps(skin_type, acne, main_product_dict, preferences)
|
| 296 |
+
|
| 297 |
+
# 4. FIX: On passe bien `routine_complete` à l'influenceur LLM
|
| 298 |
+
validated_routine = routine_with_llm(
|
| 299 |
+
routine=routine_complete,
|
| 300 |
+
skin_type=skin_type,
|
| 301 |
+
acne=acne
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
explanation = _generate_explanation(
|
| 305 |
+
doc,
|
| 306 |
+
skin_type,
|
| 307 |
+
acne,
|
| 308 |
+
preferences
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
"product_name": meta["name"],
|
| 313 |
+
"brand": meta["brand"],
|
| 314 |
+
"product_type": meta["product_type"],
|
| 315 |
+
"price_display": f"{meta['price_eur']}€" if meta["price_eur"] > 0 else "",
|
| 316 |
+
"source": meta["source"],
|
| 317 |
+
"is_french": meta["is_french"],
|
| 318 |
+
"is_vegan": meta["is_vegan"],
|
| 319 |
+
"is_clean": meta["is_clean"],
|
| 320 |
+
"explanation": explanation,
|
| 321 |
+
"routine": routine_complete,
|
| 322 |
+
"routine_validated": validated_routine
|
| 323 |
+
}
|