Aghode91 commited on
Commit
f3350ab
·
1 Parent(s): 1434dbd
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
+ }