Spaces:
Sleeping
Sleeping
Upload 21 files
Browse files- Dockerfile +19 -0
- __pycache__/main.cpython-313.pyc +0 -0
- data/united_master_database_corrected.csv +0 -0
- main.py +93 -0
- requirements.txt +10 -0
- services/__init__.py +0 -0
- services/__pycache__/__init__.cpython-313.pyc +0 -0
- services/__pycache__/classify.cpython-313.pyc +0 -0
- services/__pycache__/config.cpython-313.pyc +0 -0
- services/__pycache__/dataset.cpython-313.pyc +0 -0
- services/__pycache__/ner.cpython-313.pyc +0 -0
- services/__pycache__/recipe_service.cpython-313.pyc +0 -0
- services/__pycache__/semantic.cpython-313.pyc +0 -0
- services/__pycache__/text_utils.cpython-313.pyc +0 -0
- services/classify.py +64 -0
- services/config.py +20 -0
- services/dataset.py +210 -0
- services/ner.py +94 -0
- services/recipe_service.py +151 -0
- services/semantic.py +102 -0
- services/text_utils.py +112 -0
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("|"))
|