anaygupta commited on
Commit
4f30f49
·
verified ·
1 Parent(s): 1d4a912

Upload 21 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ git \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ COPY requirements.txt /app/requirements.txt
14
+ RUN pip install -r requirements.txt
15
+
16
+ COPY . /app
17
+
18
+ EXPOSE 7860
19
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
__pycache__/main.cpython-313.pyc ADDED
Binary file (4.17 kB). View file
 
data/united_master_database_corrected.csv ADDED
The diff for this file is too large to render. See raw diff
 
main.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Literal
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+ from pydantic import BaseModel, Field
10
+
11
+ from services.config import settings
12
+ from services.dataset import SubstitutionDatabase
13
+ from services.recipe_service import RecipeAdapterService
14
+ from services.semantic import WordVectorFallback
15
+
16
+ app = FastAPI(title="BiteWise API", version="1.1.0")
17
+
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+
27
+ class AdaptRequest(BaseModel):
28
+ recipe_text: str = Field(min_length=5)
29
+ diet: Literal["vegan", "keto", "both"] = "vegan"
30
+
31
+
32
+ _db = None
33
+ _semantic = None
34
+ _service = None
35
+
36
+
37
+ def get_service() -> RecipeAdapterService:
38
+ global _db, _semantic, _service
39
+ if _service is not None:
40
+ return _service
41
+
42
+ _db = SubstitutionDatabase(settings.dataset_path)
43
+ _semantic = WordVectorFallback(
44
+ model_name=settings.w2v_model_name,
45
+ model_path=settings.w2v_model_path,
46
+ enable_download=settings.enable_w2v_download,
47
+ )
48
+ _service = RecipeAdapterService(db=_db, semantic=_semantic)
49
+ return _service
50
+
51
+
52
+ @app.get("/")
53
+ def root():
54
+ index = Path("static/index.html")
55
+ if index.exists():
56
+ return FileResponse(index)
57
+ return JSONResponse(
58
+ {
59
+ "name": "BiteWise API",
60
+ "status": "running",
61
+ "hint": "POST /api/adapt with {recipe_text, diet}",
62
+ }
63
+ )
64
+
65
+
66
+ @app.get("/health")
67
+ def health():
68
+ return {"ok": True}
69
+
70
+
71
+ @app.get("/api/meta")
72
+ def meta():
73
+ service = get_service()
74
+ return {
75
+ "ner_model": settings.ner_model_name,
76
+ "qa_model": settings.qa_model_name,
77
+ "word2vec_available": service.semantic.available,
78
+ "word2vec_mode": service.semantic._kind,
79
+ "dataset_path": str(settings.dataset_path),
80
+ }
81
+
82
+
83
+ @app.post("/api/adapt")
84
+ def adapt(req: AdaptRequest) -> Dict[str, Any]:
85
+ try:
86
+ service = get_service()
87
+ return service.adapt(req.recipe_text, req.diet)
88
+ except FileNotFoundError as e:
89
+ raise HTTPException(status_code=500, detail=str(e))
90
+ except ValueError as e:
91
+ raise HTTPException(status_code=400, detail=str(e))
92
+ except Exception as e:
93
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {e}")
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ pydantic>=2.6
4
+ pandas>=2.1
5
+ numpy>=1.26
6
+ transformers>=4.41
7
+ torch>=2.2
8
+ gensim>=4.3
9
+ inflect>=7.2
10
+ python-multipart>=0.0.9
services/__init__.py ADDED
File without changes
services/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (144 Bytes). View file
 
services/__pycache__/classify.cpython-313.pyc ADDED
Binary file (3.23 kB). View file
 
services/__pycache__/config.cpython-313.pyc ADDED
Binary file (1.83 kB). View file
 
services/__pycache__/dataset.cpython-313.pyc ADDED
Binary file (11.9 kB). View file
 
services/__pycache__/ner.cpython-313.pyc ADDED
Binary file (3.87 kB). View file
 
services/__pycache__/recipe_service.cpython-313.pyc ADDED
Binary file (6.64 kB). View file
 
services/__pycache__/semantic.cpython-313.pyc ADDED
Binary file (5.1 kB). View file
 
services/__pycache__/text_utils.cpython-313.pyc ADDED
Binary file (5.48 kB). View file
 
services/classify.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import Literal
5
+
6
+ import torch
7
+ from transformers import AutoModelForQuestionAnswering, AutoTokenizer, pipeline
8
+
9
+ from .config import settings
10
+ from .text_utils import normalize_text
11
+
12
+ RecipeType = Literal["baked", "cooked"]
13
+
14
+ BAKE_KEYWORDS = [
15
+ "bake", "baking", "oven", "preheat", "flour", "dough", "batter",
16
+ "cake", "cookie", "muffin", "bread", "pastry", "brownie", "tart",
17
+ "pie", "scone", "loaf", "whisk", "fold in", "sift", "knead",
18
+ "leavening", "baking soda", "baking powder", "yeast",
19
+ ]
20
+ COOK_KEYWORDS = [
21
+ "saute", "sauté", "fry", "boil", "simmer", "stir", "grill",
22
+ "roast", "steam", "poach", "braise", "sear", "stove", "skillet",
23
+ "pan", "wok", "sauce", "soup", "stew", "marinate",
24
+ ]
25
+
26
+
27
+ @lru_cache(maxsize=1)
28
+ def get_qa_pipeline():
29
+ tokenizer = AutoTokenizer.from_pretrained(settings.qa_model_name)
30
+ model = AutoModelForQuestionAnswering.from_pretrained(settings.qa_model_name)
31
+ device = 0 if torch.cuda.is_available() else -1
32
+ return pipeline(
33
+ "question-answering",
34
+ model=model,
35
+ tokenizer=tokenizer,
36
+ device=device,
37
+ )
38
+
39
+
40
+ def classify_recipe(recipe_text: str) -> RecipeType:
41
+ text = normalize_text(recipe_text)
42
+
43
+ bake_score = sum(1 for kw in BAKE_KEYWORDS if kw in text)
44
+ cook_score = sum(1 for kw in COOK_KEYWORDS if kw in text)
45
+
46
+ answer = ""
47
+ try:
48
+ qa = get_qa_pipeline()
49
+ result = qa(question="Is this recipe for baking or cooking?", context=recipe_text)
50
+ answer = normalize_text(str(result.get("answer", "")))
51
+ except Exception:
52
+ pass
53
+
54
+ if any(sig in answer for sig in ("bak", "oven", "pastry", "dough")):
55
+ return "baked"
56
+ if any(sig in answer for sig in ("cook", "fry", "boil", "saut", "grill", "stir")):
57
+ return "cooked"
58
+
59
+ if bake_score > cook_score:
60
+ return "baked"
61
+ if cook_score > bake_score:
62
+ return "cooked"
63
+
64
+ return "cooked"
services/config.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Settings:
10
+ dataset_path: Path = Path(os.getenv("BITEWISE_DATASET_PATH", "data/united_master_database_corrected.csv"))
11
+ ner_model_name: str = os.getenv("BITEWISE_NER_MODEL", "Dizex/InstaFoodRoBERTa-NER")
12
+ qa_model_name: str = os.getenv("BITEWISE_QA_MODEL", "bert-large-uncased-whole-word-masking-finetuned-squad")
13
+ w2v_model_path: str = os.getenv("BITEWISE_W2V_PATH", "")
14
+ w2v_model_name: str = os.getenv("BITEWISE_W2V_MODEL", "glove-wiki-gigaword-50")
15
+ enable_w2v_download: bool = os.getenv("BITEWISE_ENABLE_W2V_DOWNLOAD", "0") == "1"
16
+ max_ingredients: int = int(os.getenv("BITEWISE_MAX_INGREDIENTS", "48"))
17
+ similarity_threshold: float = float(os.getenv("BITEWISE_SIM_THRESHOLD", "0.55"))
18
+
19
+
20
+ settings = Settings()
services/dataset.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+ import re
7
+
8
+ import pandas as pd
9
+
10
+ from .text_utils import as_aliases, ingredient_variants, normalize_text
11
+
12
+ REQUIRED_COLUMNS = [
13
+ "Ingredient",
14
+ "Context",
15
+ "Aliases",
16
+ "Category",
17
+ "Is_Keto_Friendly",
18
+ "Is_Vegan_Friendly",
19
+ "Keto_Substitution",
20
+ "Keto_Instruction",
21
+ "Vegan_Substitution",
22
+ "Vegan_Instruction",
23
+ "Vegan_Keto_Substitution",
24
+ "Vegan_Keto_Instruction",
25
+ ]
26
+
27
+ BAKE_CONTEXTS = {
28
+ "Baking & Desserts",
29
+ "Baking (Binder)",
30
+ "Baking (Leavening)",
31
+ "Pastries",
32
+ "Bagels",
33
+ "Puddings",
34
+ "Tiramisu",
35
+ }
36
+
37
+ COOK_CONTEXTS = {
38
+ "Main Course & Heavy Cooking",
39
+ "Sauces, Dips & Dressings",
40
+ "Soups & Savory Liquids",
41
+ "Cold Prep & Light Meals",
42
+ "Pasta",
43
+ "Lasagna",
44
+ "Roast",
45
+ "Stir-Fry",
46
+ "Appetizer",
47
+ "Indian",
48
+ "Beverages",
49
+ "Cheese Making",
50
+ "Processed",
51
+ "Technical & Additives",
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class IngredientRow:
57
+ ingredient: str
58
+ context: str
59
+ aliases: List[str]
60
+ category: str
61
+ is_keto_friendly: bool
62
+ is_vegan_friendly: bool
63
+ keto_substitution: Optional[str]
64
+ keto_instruction: Optional[str]
65
+ vegan_substitution: Optional[str]
66
+ vegan_instruction: Optional[str]
67
+ vegan_keto_substitution: Optional[str]
68
+ vegan_keto_instruction: Optional[str]
69
+
70
+ @property
71
+ def lookup_terms(self) -> List[str]:
72
+ return [self.ingredient, *self.aliases]
73
+
74
+
75
+ class SubstitutionDatabase:
76
+ def __init__(self, csv_path: str | Path):
77
+ self.csv_path = Path(csv_path)
78
+ self.df = self._load()
79
+ self.rows = [self._row_from_series(row) for _, row in self.df.iterrows()]
80
+ self._preferred_rows_cache: Dict[str, List[IngredientRow]] = {}
81
+
82
+ def _load(self) -> pd.DataFrame:
83
+ if not self.csv_path.exists():
84
+ raise FileNotFoundError(
85
+ f"Could not find substitution database at {self.csv_path}. "
86
+ "Place united_master_database_corrected.csv in data/ or set BITEWISE_DATASET_PATH."
87
+ )
88
+
89
+ df = pd.read_csv(self.csv_path)
90
+ missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
91
+ if missing:
92
+ raise ValueError(f"Dataset missing required columns: {missing}")
93
+
94
+ df = df.copy()
95
+ df = df.dropna(subset=["Ingredient", "Context"])
96
+ df["_ingredient_norm"] = df["Ingredient"].astype(str).map(normalize_text)
97
+ df["_aliases_norm"] = df["Aliases"].apply(as_aliases)
98
+ return df.reset_index(drop=True)
99
+
100
+ def _row_from_series(self, row: pd.Series) -> IngredientRow:
101
+ return IngredientRow(
102
+ ingredient=str(row["Ingredient"]),
103
+ context=str(row["Context"]),
104
+ aliases=as_aliases(row.get("Aliases")),
105
+ category=str(row.get("Category", "")),
106
+ is_keto_friendly=bool(row.get("Is_Keto_Friendly", False)),
107
+ is_vegan_friendly=bool(row.get("Is_Vegan_Friendly", False)),
108
+ keto_substitution=None if pd.isna(row.get("Keto_Substitution")) else str(row.get("Keto_Substitution")),
109
+ keto_instruction=None if pd.isna(row.get("Keto_Instruction")) else str(row.get("Keto_Instruction")),
110
+ vegan_substitution=None if pd.isna(row.get("Vegan_Substitution")) else str(row.get("Vegan_Substitution")),
111
+ vegan_instruction=None if pd.isna(row.get("Vegan_Instruction")) else str(row.get("Vegan_Instruction")),
112
+ vegan_keto_substitution=None if pd.isna(row.get("Vegan_Keto_Substitution")) else str(row.get("Vegan_Keto_Substitution")),
113
+ vegan_keto_instruction=None if pd.isna(row.get("Vegan_Keto_Instruction")) else str(row.get("Vegan_Keto_Instruction")),
114
+ )
115
+
116
+ def contexts_for_recipe_type(self, recipe_type: str) -> set[str]:
117
+ return BAKE_CONTEXTS if recipe_type == "baked" else COOK_CONTEXTS
118
+
119
+ def preferred_rows(self, recipe_type: str) -> List[IngredientRow]:
120
+ if recipe_type not in self._preferred_rows_cache:
121
+ contexts = self.contexts_for_recipe_type(recipe_type)
122
+ self._preferred_rows_cache[recipe_type] = [row for row in self.rows if row.context in contexts]
123
+ return self._preferred_rows_cache[recipe_type]
124
+
125
+ def _normalize_terms(self, row: IngredientRow) -> List[str]:
126
+ return [normalize_text(term) for term in row.lookup_terms if normalize_text(term)]
127
+
128
+ def _match_exact(self, rows: List[IngredientRow], query: str) -> List[IngredientRow]:
129
+ query = normalize_text(query)
130
+ exact_rows = []
131
+ for row in rows:
132
+ terms = self._normalize_terms(row)
133
+ if not terms:
134
+ continue
135
+ if normalize_text(row.ingredient) == query:
136
+ exact_rows.append(row)
137
+ continue
138
+ if query in terms:
139
+ exact_rows.append(row)
140
+ return exact_rows
141
+
142
+ def _match_partial(self, rows: List[IngredientRow], query: str) -> List[IngredientRow]:
143
+ query = normalize_text(query)
144
+ if len(query) < 4:
145
+ return []
146
+ pattern = re.compile(rf"(?<!\w){re.escape(query)}(?!\w)")
147
+ partial_rows = []
148
+ for row in rows:
149
+ candidates = [normalize_text(row.ingredient), *self._normalize_terms(row)]
150
+ if any(pattern.search(candidate) for candidate in candidates):
151
+ partial_rows.append(row)
152
+ return partial_rows
153
+
154
+ def _rank_rows(self, rows: List[IngredientRow], query: str) -> List[IngredientRow]:
155
+ """Prefer exact ingredient matches over aliases, then longer ingredient names."""
156
+ q = normalize_text(query)
157
+
158
+ def score(row: IngredientRow) -> tuple[int, int, int]:
159
+ ingredient_norm = normalize_text(row.ingredient)
160
+ alias_norms = [normalize_text(a) for a in row.aliases]
161
+ exact_ingredient = int(ingredient_norm == q)
162
+ exact_alias = int(q in alias_norms)
163
+ # longer exact ingredient names are usually more specific and safer
164
+ length = len(ingredient_norm)
165
+ return (exact_ingredient, exact_alias, length)
166
+
167
+ return sorted(rows, key=score, reverse=True)
168
+
169
+ def find_rows(self, query: str, recipe_type: str) -> List[IngredientRow]:
170
+ query = normalize_text(query)
171
+ if not query:
172
+ return []
173
+
174
+ preferred = self.preferred_rows(recipe_type)
175
+ exact_preferred = self._match_exact(preferred, query)
176
+ if exact_preferred:
177
+ return self._rank_rows(exact_preferred, query)
178
+
179
+ exact_all = self._match_exact(self.rows, query)
180
+ if exact_all:
181
+ return self._rank_rows(exact_all, query)
182
+
183
+ partial_preferred = self._match_partial(preferred, query)
184
+ if partial_preferred:
185
+ return self._rank_rows(partial_preferred, query)
186
+
187
+ partial_all = self._match_partial(self.rows, query)
188
+ if partial_all:
189
+ return self._rank_rows(partial_all, query)
190
+
191
+ return []
192
+
193
+ def pick_substitution(self, row: IngredientRow, diet: str) -> tuple[str, str, bool]:
194
+ if diet == "vegan":
195
+ sub = row.vegan_substitution
196
+ instr = row.vegan_instruction
197
+ compatible = row.is_vegan_friendly
198
+ elif diet == "keto":
199
+ sub = row.keto_substitution
200
+ instr = row.keto_instruction
201
+ compatible = row.is_keto_friendly
202
+ else:
203
+ sub = row.vegan_keto_substitution
204
+ instr = row.vegan_keto_instruction
205
+ compatible = row.is_vegan_friendly and row.is_keto_friendly
206
+
207
+ if compatible or not sub or str(sub).strip().lower() in {"nan", "none"}:
208
+ return row.ingredient, "Already compatible — no substitution needed.", True
209
+
210
+ return str(sub), (str(instr) if instr and str(instr).lower() != "nan" else ""), False
services/ner.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import List
5
+
6
+ import torch
7
+ from transformers import AutoModelForTokenClassification, AutoTokenizer, pipeline
8
+
9
+ from .config import settings
10
+ from .text_utils import dedupe_preserve_order, strip_amounts_and_preps, tokenize_recipe_segments
11
+
12
+
13
+ @lru_cache(maxsize=1)
14
+ def get_ner_pipeline():
15
+ tokenizer = AutoTokenizer.from_pretrained(settings.ner_model_name)
16
+ model = AutoModelForTokenClassification.from_pretrained(settings.ner_model_name)
17
+ device = 0 if torch.cuda.is_available() else -1
18
+ return pipeline(
19
+ "token-classification",
20
+ model=model,
21
+ tokenizer=tokenizer,
22
+ aggregation_strategy="simple",
23
+ device=device,
24
+ )
25
+
26
+
27
+ def _merge_adjacent_entities(entities: List[dict]) -> List[dict]:
28
+ merged: List[dict] = []
29
+ for ent in entities:
30
+ start = int(ent.get("start", 0))
31
+ end = int(ent.get("end", 0))
32
+ label = str(ent.get("entity_group", ""))
33
+ if not label or end <= start:
34
+ continue
35
+
36
+ if merged and merged[-1]["label"] == label and -1 <= start - merged[-1]["end"] <= 1:
37
+ merged[-1]["end"] = end
38
+ continue
39
+
40
+ merged.append({"start": start, "end": end, "label": label})
41
+ return merged
42
+
43
+
44
+ def _extract_spans(text: str) -> List[str]:
45
+ try:
46
+ ner = get_ner_pipeline()
47
+ entities = ner(text)
48
+ except Exception:
49
+ return []
50
+
51
+ merged = _merge_adjacent_entities(entities)
52
+ spans: List[str] = []
53
+ for ent in merged:
54
+ span = text[ent["start"]:ent["end"]].strip(" ,.;:\n\t")
55
+ span = strip_amounts_and_preps(span)
56
+ if len(span) >= 3:
57
+ spans.append(span)
58
+ return dedupe_preserve_order(spans)
59
+
60
+
61
+ def extract_ingredients(text: str, max_items: int = 48) -> List[str]:
62
+ """Extract ingredient-like tokens from a recipe.
63
+
64
+ We keep the MVP behaviour simple: split the recipe into comma-separated
65
+ chunks, run RoBERTa NER per chunk, and fall back to the cleaned chunk when
66
+ the model misses it.
67
+ """
68
+ chunks = tokenize_recipe_segments(text)
69
+ if not chunks:
70
+ chunks = [strip_amounts_and_preps(text)]
71
+
72
+ extracted: List[str] = []
73
+ for chunk in chunks:
74
+ if not chunk:
75
+ continue
76
+
77
+ spans = _extract_spans(chunk)
78
+ if spans:
79
+ extracted.extend(spans)
80
+ else:
81
+ cleaned = strip_amounts_and_preps(chunk)
82
+ if cleaned and len(cleaned) >= 2:
83
+ extracted.append(cleaned)
84
+
85
+ # Keep only meaningful spans; short fragments tend to be false positives.
86
+ cleaned = []
87
+ for item in dedupe_preserve_order(extracted):
88
+ if len(item) < 3:
89
+ continue
90
+ if item in {"and", "or", "the", "a", "an"}:
91
+ continue
92
+ cleaned.append(item)
93
+
94
+ return cleaned[:max_items]
services/recipe_service.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Literal, Optional
5
+
6
+ from .classify import classify_recipe
7
+ from .config import settings
8
+ from .dataset import SubstitutionDatabase
9
+ from .ner import extract_ingredients
10
+ from .semantic import WordVectorFallback
11
+ from .text_utils import normalize_text
12
+
13
+ Diet = Literal["vegan", "keto", "both"]
14
+
15
+
16
+ @dataclass
17
+ class IngredientResult:
18
+ original: str
19
+ normalized: str
20
+ compliant: bool
21
+ substitute: str
22
+ instructions: str
23
+ source: str
24
+ matched_ingredient: Optional[str] = None
25
+ confidence: float = 1.0
26
+ notes: Optional[str] = None
27
+
28
+ def as_dict(self) -> Dict[str, Any]:
29
+ payload = {
30
+ "original": self.original,
31
+ "normalized": self.normalized,
32
+ "compliant": self.compliant,
33
+ "substitute": self.substitute,
34
+ "instructions": self.instructions,
35
+ "source": self.source,
36
+ "matched_ingredient": self.matched_ingredient,
37
+ "confidence": round(float(self.confidence), 3),
38
+ }
39
+ if self.notes:
40
+ payload["notes"] = self.notes
41
+ return payload
42
+
43
+
44
+ class RecipeAdapterService:
45
+ def __init__(self, db: SubstitutionDatabase, semantic: WordVectorFallback):
46
+ self.db = db
47
+ self.semantic = semantic
48
+
49
+ def _row_to_result(self, ingredient: str, row, diet: Diet) -> IngredientResult:
50
+ substitute, instructions, compliant = self.db.pick_substitution(row, diet)
51
+ normalized = normalize_text(ingredient)
52
+
53
+ if compliant:
54
+ return IngredientResult(
55
+ original=ingredient,
56
+ normalized=normalized,
57
+ compliant=True,
58
+ substitute=ingredient,
59
+ instructions="Already compatible — no substitution needed.",
60
+ source="database match",
61
+ matched_ingredient=row.ingredient,
62
+ confidence=1.0,
63
+ )
64
+
65
+ return IngredientResult(
66
+ original=ingredient,
67
+ normalized=normalized,
68
+ compliant=False,
69
+ substitute=substitute,
70
+ instructions=instructions,
71
+ source="database match",
72
+ matched_ingredient=row.ingredient,
73
+ confidence=0.96,
74
+ )
75
+
76
+ def _semantic_fallback(self, ingredient: str, diet: Diet, recipe_type: str) -> Optional[IngredientResult]:
77
+ candidates = list(dict.fromkeys(row.ingredient for row in self.db.rows))
78
+ if not candidates:
79
+ return None
80
+
81
+ hits = self.semantic.nearest(ingredient, candidates, top_k=5)
82
+ if not hits:
83
+ return None
84
+
85
+ for hit in hits:
86
+ matched_rows = self.db.find_rows(hit.term, recipe_type)
87
+ if not matched_rows:
88
+ continue
89
+ row = matched_rows[0]
90
+ result = self._row_to_result(ingredient, row, diet)
91
+ result.source = f"word2vec semantic match ({hit.term}, score={hit.score:.2f})"
92
+ result.confidence = max(0.5, min(0.95, hit.score))
93
+ return result
94
+
95
+ return None
96
+
97
+ def adapt(self, recipe_text: str, diet: Diet) -> Dict[str, Any]:
98
+ if diet not in {"vegan", "keto", "both"}:
99
+ raise ValueError("diet must be one of: vegan, keto, both")
100
+
101
+ recipe_text = (recipe_text or "").strip()
102
+ if len(recipe_text) < 5:
103
+ raise ValueError("recipe_text is too short")
104
+
105
+ recipe_type = classify_recipe(recipe_text)
106
+ ingredients = extract_ingredients(recipe_text, max_items=settings.max_ingredients)
107
+
108
+ results: List[IngredientResult] = []
109
+ substitutions = 0
110
+
111
+ for ingredient in ingredients:
112
+ matches = self.db.find_rows(ingredient, recipe_type)
113
+ if matches:
114
+ result = self._row_to_result(ingredient, matches[0], diet)
115
+ else:
116
+ result = self._semantic_fallback(ingredient, diet, recipe_type)
117
+ if result is None:
118
+ result = IngredientResult(
119
+ original=ingredient,
120
+ normalized=normalize_text(ingredient),
121
+ compliant=True,
122
+ substitute=ingredient,
123
+ instructions="No substitution needed.",
124
+ source="not in database — assumed compatible",
125
+ matched_ingredient=None,
126
+ confidence=0.4,
127
+ notes="No reliable database or semantic match found.",
128
+ )
129
+
130
+ if not result.compliant:
131
+ substitutions += 1
132
+ results.append(result)
133
+
134
+ return {
135
+ "diet": diet,
136
+ "recipe_type": recipe_type,
137
+ "ingredients": [r.as_dict() for r in results],
138
+ "ingredients_found": len(results),
139
+ "substitution_count": substitutions,
140
+ "model_metadata": {
141
+ "ner_model": settings.ner_model_name,
142
+ "qa_model": settings.qa_model_name,
143
+ "word2vec_available": self.semantic.available,
144
+ "word2vec_mode": self.semantic._kind,
145
+ "dataset_path": str(self.db.csv_path),
146
+ },
147
+ "disclaimer": (
148
+ "This is an assistive recipe tool, not nutritional or allergen medical advice. "
149
+ "Verify substitutions before cooking."
150
+ ),
151
+ }
services/semantic.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable, List, Optional
5
+
6
+ import numpy as np
7
+
8
+ from .text_utils import normalize_text, singularize
9
+
10
+
11
+ @dataclass
12
+ class SemanticHit:
13
+ term: str
14
+ score: float
15
+
16
+
17
+ class WordVectorFallback:
18
+ """Small semantic fallback using vector similarity when available.
19
+
20
+ It is intentionally optional so the app can still run on free hosts even if
21
+ no embedding model is downloaded.
22
+ """
23
+
24
+ def __init__(self, model_name: str = "glove-wiki-gigaword-50", model_path: str = "", enable_download: bool = False):
25
+ self.model = None
26
+ self._kind = "disabled"
27
+ self._load(model_name=model_name, model_path=model_path, enable_download=enable_download)
28
+
29
+ def _load(self, model_name: str, model_path: str, enable_download: bool) -> None:
30
+ try:
31
+ from gensim.models import KeyedVectors
32
+ import gensim.downloader as api
33
+ except Exception:
34
+ self.model = None
35
+ self._kind = "unavailable"
36
+ return
37
+
38
+ if model_path:
39
+ try:
40
+ self.model = KeyedVectors.load(model_path, mmap="r")
41
+ self._kind = f"local:{model_path}"
42
+ return
43
+ except Exception:
44
+ try:
45
+ self.model = KeyedVectors.load_word2vec_format(model_path, binary=model_path.endswith(".bin"))
46
+ self._kind = f"local-vec:{model_path}"
47
+ return
48
+ except Exception:
49
+ self.model = None
50
+
51
+ if enable_download:
52
+ try:
53
+ self.model = api.load(model_name)
54
+ self._kind = model_name
55
+ except Exception:
56
+ self.model = None
57
+ self._kind = "download-failed"
58
+ else:
59
+ self.model = None
60
+ self._kind = "disabled"
61
+
62
+ @property
63
+ def available(self) -> bool:
64
+ return self.model is not None
65
+
66
+ def vector_for(self, phrase: str) -> Optional[np.ndarray]:
67
+ if not self.available:
68
+ return None
69
+
70
+ tokens = [singularize(t) for t in normalize_text(phrase).split()]
71
+ vectors = []
72
+ for token in tokens:
73
+ if token in self.model:
74
+ vectors.append(self.model[token])
75
+
76
+ if vectors:
77
+ return np.mean(np.stack(vectors), axis=0)
78
+
79
+ phrase_key = normalize_text(phrase).replace(" ", "_")
80
+ if phrase_key in self.model:
81
+ return self.model[phrase_key]
82
+
83
+ return None
84
+
85
+ def nearest(self, query: str, candidates: Iterable[str], top_k: int = 3) -> List[SemanticHit]:
86
+ if not self.available:
87
+ return []
88
+
89
+ qv = self.vector_for(query)
90
+ if qv is None:
91
+ return []
92
+
93
+ scored: List[SemanticHit] = []
94
+ for candidate in candidates:
95
+ cv = self.vector_for(candidate)
96
+ if cv is None:
97
+ continue
98
+ score = float(np.dot(qv, cv) / (np.linalg.norm(qv) * np.linalg.norm(cv) + 1e-8))
99
+ scored.append(SemanticHit(term=candidate, score=score))
100
+
101
+ scored.sort(key=lambda x: x.score, reverse=True)
102
+ return scored[:top_k]
services/text_utils.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import lru_cache
5
+ from typing import Iterable, List
6
+
7
+ import inflect
8
+
9
+ _p = inflect.engine()
10
+
11
+
12
+ def normalize_text(text: str) -> str:
13
+ text = (text or "").lower().strip()
14
+ text = re.sub(r"[\u2018\u2019\u201c\u201d]", "'", text)
15
+ text = re.sub(r"[^a-z0-9\s\-']+", " ", text)
16
+ text = re.sub(r"\s+", " ", text).strip()
17
+ return text
18
+
19
+
20
+ @lru_cache(maxsize=4096)
21
+ def singularize(word: str) -> str:
22
+ word = normalize_text(word)
23
+ singular = _p.singular_noun(word)
24
+ return singular if singular else word
25
+
26
+
27
+ def dedupe_preserve_order(items: Iterable[str]) -> List[str]:
28
+ seen = set()
29
+ out = []
30
+ for item in items:
31
+ item = normalize_text(item)
32
+ if item and item not in seen:
33
+ seen.add(item)
34
+ out.append(item)
35
+ return out
36
+
37
+
38
+ _AMOUNT_RE = re.compile(
39
+ r"^(?:\d+(?:\.\d+)?|\d+/\d+|[\u00bc\u00bd\u00be\u2153\u2154\u215b\u215c\u215d\u215e])\s*"
40
+ )
41
+ _MEASURE_RE = re.compile(
42
+ r"^(?:g|kg|mg|ml|l|oz|lb|lbs|cup|cups|tbsp|tablespoon|tsp|teaspoon|clove|cloves|slice|slices|piece|pieces|can|cans|bunch|handful|pinch|large|small|medium|whole)\s+"
43
+ )
44
+
45
+
46
+ def strip_amounts_and_preps(text: str) -> str:
47
+ """Remove leading quantities and prep words from a recipe fragment."""
48
+ text = normalize_text(text)
49
+ text = _AMOUNT_RE.sub("", text)
50
+ text = _MEASURE_RE.sub("", text)
51
+ text = re.sub(r"^of\s+", "", text)
52
+ text = re.sub(r"\(.*?\)", "", text)
53
+ text = re.sub(r"\s+", " ", text).strip()
54
+ return text
55
+
56
+
57
+ def tokenize_recipe_segments(text: str) -> List[str]:
58
+ """Split a recipe into ingredient-like chunks.
59
+
60
+ The MVP demo worked best with comma-separated ingredients, so we keep that
61
+ behaviour first and only use a small fallback split when the recipe has no
62
+ commas.
63
+ """
64
+ raw = text or ""
65
+ parts = re.split(r",|\n|;", raw)
66
+ if len(parts) == 1:
67
+ parts = re.split(r"\s+and\s+", raw, flags=re.IGNORECASE)
68
+
69
+ cleaned = []
70
+ for part in parts:
71
+ item = strip_amounts_and_preps(part)
72
+ if item and len(item) > 1:
73
+ cleaned.append(item)
74
+ return dedupe_preserve_order(cleaned)
75
+
76
+
77
+ def ingredient_variants(ingredient: str) -> List[str]:
78
+ ing = normalize_text(ingredient)
79
+ variants = [ing]
80
+
81
+ singular = singularize(ing)
82
+ if singular != ing:
83
+ variants.append(singular)
84
+
85
+ suffixes = [" cheese", " oil", " milk", " cream", " butter", " powder", " sauce", " paste", " extract"]
86
+ for suffix in suffixes:
87
+ if ing.endswith(suffix) and len(ing) > len(suffix) + 1:
88
+ base = ing[:-len(suffix)].strip()
89
+ variants.append(base)
90
+ base_singular = singularize(base)
91
+ if base_singular != base:
92
+ variants.append(base_singular)
93
+
94
+ words = ing.split()
95
+ if len(words) > 1:
96
+ variants.extend([
97
+ words[0],
98
+ words[-1],
99
+ " ".join(words[:2]),
100
+ " ".join(words[1:]),
101
+ ])
102
+
103
+ # very short fragments create false matches, so keep them only as a last resort
104
+ return dedupe_preserve_order(variants)
105
+
106
+
107
+ def as_aliases(aliases: str | float | None) -> List[str]:
108
+ if aliases is None:
109
+ return []
110
+ if not isinstance(aliases, str):
111
+ return []
112
+ return dedupe_preserve_order(alias.strip() for alias in aliases.split("|"))