anaygupta commited on
Commit
a121d44
·
verified ·
1 Parent(s): 72bf70a

Update services/recipe_service.py

Browse files
Files changed (1) hide show
  1. services/recipe_service.py +370 -59
services/recipe_service.py CHANGED
@@ -1,63 +1,374 @@
1
- def _head_token(text: str) -> str:
2
- parts = (text or "").split()
3
- return parts[-1] if parts else ""
4
-
5
- def _token_overlap(a: str, b: str) -> bool:
6
- sa = set((a or "").split())
7
- sb = set((b or "").split())
8
- return bool(sa & sb)
9
-
10
- def lookup(self, ingredient: str, recipe_type: str, diet: str):
11
- preferred = BAKE_CONTEXTS if recipe_type == "baked" else COOK_CONTEXTS
12
- variants = ingredient_lookup_variants(ingredient)
13
- query_norm = normalize_ingredient_for_lookup(ingredient)
14
- query_head = _head_token(query_norm)
15
-
16
- # 1) exact match first
17
- for v in variants:
18
- row = self.db.find_exact(v, preferred_contexts=preferred)
19
- if row is not None:
20
- return self._format_result(ingredient, row, diet, "database match", 1.0)
21
-
22
- # 2) alias / partial match second, but only for real ingredient-length tokens
23
- for v in variants:
24
- if len(v) < 4:
25
- continue
26
- row = self.db.find_partial(v, preferred_contexts=preferred)
27
- if row is not None:
28
- return self._format_result(ingredient, row, diet, "partial match", 0.85)
29
-
30
- # 3) semantic fallback only if it is actually close AND same ingredient family
31
- if self.semantic and self.semantic.available:
32
- best = self.semantic.most_similar(query_norm, topn=10)
33
-
34
- for candidate_text, score in best:
35
- if score < 0.82:
36
- break
37
-
38
- cand_norm = normalize_ingredient_for_lookup(candidate_text)
39
- cand_head = _head_token(cand_norm)
40
-
41
- # reject nonsense like spinach -> sparkling water
42
- if query_head and cand_head and query_head != cand_head and not _token_overlap(query_norm, cand_norm):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  continue
44
 
45
- row = self.db.find_exact(cand_norm, preferred_contexts=preferred)
46
- if row is None:
47
- row = self.db.find_partial(cand_norm, preferred_contexts=preferred)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  if row is not None:
50
- return self._format_result(ingredient, row, diet, f"semantic match ({candidate_text})", float(score))
51
-
52
- # 4) do NOT guess
53
- return {
54
- "original": ingredient,
55
- "normalized": query_norm,
56
- "compliant": False,
57
- "substitute": ingredient,
58
- "instructions": "No reliable match found — verify manually.",
59
- "source": "unmatched",
60
- "matched_ingredient": None,
61
- "confidence": 0.0,
62
- "notes": "No exact, alias, or safe semantic match found.",
63
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
5
+
6
+ from .classify import classify_recipe
7
+ from .ner import extract_ingredients
8
+ from .text_utils import (
9
+ as_aliases,
10
+ ingredient_lookup_variants,
11
+ normalize_ingredient_for_lookup,
12
+ )
13
+
14
+ try:
15
+ import pandas as pd
16
+ except Exception: # pragma: no cover
17
+ pd = None
18
+
19
+
20
+ BAKE_CONTEXTS = {
21
+ "Baking & Desserts",
22
+ "Baking (Binder)",
23
+ "Baking (Leavening)",
24
+ "Pastries",
25
+ "Bagels",
26
+ "Puddings",
27
+ "Tiramisu",
28
+ }
29
+
30
+ COOK_CONTEXTS = {
31
+ "Main Course & Heavy Cooking",
32
+ "Sauces, Dips & Dressings",
33
+ "Soups & Savory Liquids",
34
+ "Cold Prep & Light Meals",
35
+ "Pasta",
36
+ "Lasagna",
37
+ "Roast",
38
+ "Stir-Fry",
39
+ "Appetizer",
40
+ "Indian",
41
+ "Beverages",
42
+ "Cheese Making",
43
+ "Processed",
44
+ "Technical & Additives",
45
+ }
46
+
47
+
48
+ def _clean_value(value: Any) -> str:
49
+ if value is None:
50
+ return ""
51
+ try:
52
+ if pd is not None and pd.isna(value):
53
+ return ""
54
+ except Exception:
55
+ pass
56
+ text = str(value).strip()
57
+ if text.lower() == "nan":
58
+ return ""
59
+ return text
60
+
61
+
62
+ def _pick_first(row: Any, keys: Iterable[str]) -> str:
63
+ for key in keys:
64
+ if isinstance(row, dict) and key in row:
65
+ value = _clean_value(row.get(key))
66
+ if value:
67
+ return value
68
+ else:
69
+ try:
70
+ value = _clean_value(row[key])
71
+ if value:
72
+ return value
73
+ except Exception:
74
+ pass
75
+ return ""
76
+
77
+
78
+ def _row_to_dict(row: Any) -> Dict[str, Any]:
79
+ if row is None:
80
+ return {}
81
+ if isinstance(row, dict):
82
+ return dict(row)
83
+ if hasattr(row, "to_dict"):
84
+ try:
85
+ return row.to_dict()
86
+ except Exception:
87
+ return {}
88
+ return {}
89
+
90
+
91
+ @dataclass
92
+ class MatchResult:
93
+ original: str
94
+ normalized: str
95
+ compliant: bool
96
+ substitute: str
97
+ instructions: str
98
+ source: str
99
+ matched_ingredient: Optional[str]
100
+ confidence: float
101
+ notes: str = ""
102
+
103
+ def to_dict(self) -> Dict[str, Any]:
104
+ return {
105
+ "original": self.original,
106
+ "normalized": self.normalized,
107
+ "compliant": self.compliant,
108
+ "substitute": self.substitute,
109
+ "instructions": self.instructions,
110
+ "source": self.source,
111
+ "matched_ingredient": self.matched_ingredient,
112
+ "confidence": self.confidence,
113
+ "notes": self.notes,
114
+ }
115
+
116
+
117
+ class RecipeAdapterService:
118
+ def __init__(self, db, semantic):
119
+ self.db = db
120
+ self.semantic = semantic
121
+
122
+ def _frame(self):
123
+ for attr in ("df", "dataframe", "frame", "table"):
124
+ frame = getattr(self.db, attr, None)
125
+ if frame is not None:
126
+ return frame
127
+ return None
128
+
129
+ def _context_filter(self, recipe_type: str):
130
+ return BAKE_CONTEXTS if recipe_type == "baked" else COOK_CONTEXTS
131
+
132
+ def _friendly_col(self, diet: str) -> str:
133
+ if diet == "vegan":
134
+ return "Is_Vegan"
135
+ if diet == "keto":
136
+ return "Is_Keto_Friendly"
137
+ return ""
138
+
139
+ def _sub_col(self, diet: str) -> str:
140
+ return {
141
+ "vegan": "Vegan_Substitution",
142
+ "keto": "Keto_Substitution",
143
+ "both": "Vegan_Substitution",
144
+ }[diet]
145
+
146
+ def _instr_col(self, diet: str) -> str:
147
+ return {
148
+ "vegan": "Vegan_Instruction",
149
+ "keto": "Keto_Instruction",
150
+ "both": "Vegan_Instruction",
151
+ }[diet]
152
+
153
+ def _find_in_frame(self, query: str, recipe_type: str, exact: bool = True):
154
+ frame = self._frame()
155
+ if frame is None:
156
+ return None
157
+
158
+ preferred = self._context_filter(recipe_type)
159
+
160
+ try:
161
+ rows = frame
162
+ if "Context" in frame.columns:
163
+ pref_rows = frame[frame["Context"].isin(preferred)]
164
+ rows = pref_rows if not pref_rows.empty else frame
165
+ except Exception:
166
+ rows = frame
167
+
168
+ try:
169
+ if exact:
170
+ if "Ingredient" in rows.columns:
171
+ m = rows[rows["Ingredient"].astype(str).str.lower() == query]
172
+ if not m.empty:
173
+ return m.iloc[0]
174
+ else:
175
+ if "Ingredient" in rows.columns:
176
+ pattern = r"\b" + query.replace(" ", r"\s+") + r"\b"
177
+ m = rows[rows["Ingredient"].astype(str).str.lower().str.contains(pattern, regex=True, na=False)]
178
+ if not m.empty:
179
+ return m.iloc[0]
180
+ except Exception:
181
+ return None
182
+
183
+ return None
184
+
185
+ def _find_alias(self, query: str, recipe_type: str):
186
+ frame = self._frame()
187
+ if frame is None or "aliases" not in frame.columns and "Aliases" not in frame.columns:
188
+ return None
189
+
190
+ alias_col = "aliases" if "aliases" in frame.columns else "Aliases"
191
+ preferred = self._context_filter(recipe_type)
192
+
193
+ try:
194
+ rows = frame
195
+ if "Context" in frame.columns:
196
+ pref_rows = frame[frame["Context"].isin(preferred)]
197
+ rows = pref_rows if not pref_rows.empty else frame
198
+ except Exception:
199
+ rows = frame
200
+
201
+ try:
202
+ for idx, row in rows.iterrows():
203
+ ingredient = _clean_value(row.get("Ingredient"))
204
+ aliases = as_aliases(row.get(alias_col))
205
+ if query == ingredient.lower() or query in aliases:
206
+ return row
207
+ except Exception:
208
+ return None
209
+
210
+ return None
211
+
212
+ def _resolve_row(self, ingredient: str, diet: str, recipe_type: str, row: Any, source: str, confidence: float):
213
+ rowd = _row_to_dict(row)
214
+
215
+ sub = _clean_value(rowd.get(self._sub_col(diet)))
216
+ instr = _clean_value(rowd.get(self._instr_col(diet)))
217
+ ing_name = _clean_value(rowd.get("Ingredient")) or ingredient
218
+
219
+ friendly_col = self._friendly_col(diet)
220
+ already_ok = False
221
+ if friendly_col and friendly_col in rowd:
222
+ val = rowd.get(friendly_col)
223
+ already_ok = bool(val) is True
224
+
225
+ if not sub or already_ok:
226
+ return MatchResult(
227
+ original=ingredient,
228
+ normalized=normalize_ingredient_for_lookup(ingredient),
229
+ compliant=True,
230
+ substitute=ingredient,
231
+ instructions="Already compatible — no substitution needed.",
232
+ source=source,
233
+ matched_ingredient=ing_name or None,
234
+ confidence=1.0 if already_ok else confidence,
235
+ notes="",
236
+ ).to_dict()
237
+
238
+ return MatchResult(
239
+ original=ingredient,
240
+ normalized=normalize_ingredient_for_lookup(ingredient),
241
+ compliant=False,
242
+ substitute=sub,
243
+ instructions=instr,
244
+ source=source,
245
+ matched_ingredient=ing_name or None,
246
+ confidence=confidence,
247
+ notes="",
248
+ ).to_dict()
249
+
250
+ def _safe_semantic_match(self, query: str, recipe_type: str):
251
+ if self.semantic is None or not getattr(self.semantic, "available", False):
252
+ return None
253
+
254
+ query_norm = normalize_ingredient_for_lookup(query)
255
+ variants = ingredient_lookup_variants(query_norm)
256
+
257
+ # Conservative threshold so the model does not invent unrelated foods.
258
+ threshold = 0.82
259
+
260
+ for variant in variants:
261
+ try:
262
+ candidates = self.semantic.most_similar(variant, topn=10)
263
+ except Exception:
264
  continue
265
 
266
+ for candidate, score in candidates:
267
+ if score < threshold:
268
+ break
269
+
270
+ candidate_norm = normalize_ingredient_for_lookup(candidate)
271
+ if not candidate_norm:
272
+ continue
273
+
274
+ row = self._find_in_frame(candidate_norm, recipe_type, exact=True)
275
+ if row is None:
276
+ row = self._find_alias(candidate_norm, recipe_type)
277
+ if row is None:
278
+ row = self._find_in_frame(candidate_norm, recipe_type, exact=False)
279
+
280
+ if row is not None:
281
+ return row, f"semantic match ({candidate_norm})", float(score)
282
+
283
+ return None
284
 
285
+ def adapt(self, recipe_text: str, diet: str) -> Dict[str, Any]:
286
+ if diet not in {"vegan", "keto", "both"}:
287
+ raise ValueError("diet must be vegan, keto, or both")
288
+
289
+ recipe_type = classify_recipe(recipe_text)
290
+ ingredients = extract_ingredients(recipe_text)
291
+
292
+ if not ingredients:
293
+ raise ValueError("Could not extract any ingredients from the recipe text.")
294
+
295
+ results: List[Dict[str, Any]] = []
296
+ substitution_count = 0
297
+
298
+ for ing in ingredients:
299
+ query = normalize_ingredient_for_lookup(ing)
300
+ row = None
301
+ source = ""
302
+ confidence = 1.0
303
+
304
+ # 1) exact match
305
+ row = self._find_in_frame(query, recipe_type, exact=True)
306
+ if row is not None:
307
+ source = "database match"
308
+ result = self._resolve_row(ing, diet, recipe_type, row, source, confidence)
309
+ results.append(result)
310
+ if not result["compliant"]:
311
+ substitution_count += 1
312
+ continue
313
+
314
+ # 2) alias match
315
+ row = self._find_alias(query, recipe_type)
316
  if row is not None:
317
+ source = "alias match"
318
+ result = self._resolve_row(ing, diet, recipe_type, row, source, 0.9)
319
+ results.append(result)
320
+ if not result["compliant"]:
321
+ substitution_count += 1
322
+ continue
323
+
324
+ # 3) partial match, but only as a last non-semantic direct match
325
+ row = self._find_in_frame(query, recipe_type, exact=False)
326
+ if row is not None and len(query) >= 4:
327
+ source = "partial match"
328
+ result = self._resolve_row(ing, diet, recipe_type, row, source, 0.8)
329
+ results.append(result)
330
+ if not result["compliant"]:
331
+ substitution_count += 1
332
+ continue
333
+
334
+ # 4) semantic match with conservative threshold
335
+ semantic_hit = self._safe_semantic_match(ing, recipe_type)
336
+ if semantic_hit is not None:
337
+ row, source, confidence = semantic_hit
338
+ result = self._resolve_row(ing, diet, recipe_type, row, source, confidence)
339
+ results.append(result)
340
+ if not result["compliant"]:
341
+ substitution_count += 1
342
+ continue
343
+
344
+ # 5) do not guess
345
+ results.append(
346
+ MatchResult(
347
+ original=ing,
348
+ normalized=query,
349
+ compliant=False,
350
+ substitute=ing,
351
+ instructions="No reliable match found — verify manually.",
352
+ source="unmatched",
353
+ matched_ingredient=None,
354
+ confidence=0.0,
355
+ notes="No exact, alias, or safe semantic match found.",
356
+ ).to_dict()
357
+ )
358
+
359
+ return {
360
+ "diet": diet,
361
+ "recipe_type": recipe_type,
362
+ "ingredients": results,
363
+ "ingredients_found": len(results),
364
+ "substitution_count": substitution_count,
365
+ "model_metadata": {
366
+ "ner_model": "ingredient-parser-nlp (comma-separated parsing)",
367
+ "qa_model": "keyword fallback only",
368
+ "semantic_model": getattr(self.semantic, "model_name", "unknown"),
369
+ "semantic_available": bool(getattr(self.semantic, "available", False)),
370
+ "semantic_mode": getattr(self.semantic, "_kind", "disabled"),
371
+ "dataset_path": str(getattr(self.db, "path", "")),
372
+ },
373
+ "disclaimer": "This is an assistive recipe tool, not nutritional or allergen medical advice. Verify substitutions before cooking.",
374
+ }