The Integrations
The domain definition can store recipes and profiles, but nothing fills the tables. This page adds the integrations that do. It defines two integration types, a recipe source and a user source, the managers that store what they fetch, and a sample integration of each type. The concepts are covered in Integrations, and the part of a domain definition that defines them in Domain definitions.
An integration type is split in two. The type states what a source of recipes must provide, and the manager turns what it provides into entities. Integrations only implement the type, so a new source of recipes, such as a food blog or a meal-kit service, is one new class and no change to the code that stores recipes.
New files on this page:
diet_domain/
├── structures.py # extended
├── classification.py # allergens and dietary tags from ingredients
├── sync/
│ ├── __init__.py
│ ├── recipe.py
│ └── user.py
├── integrations.py # the two integration types
└── sources/
├── __init__.py
├── sample_recipe_source.py
└── sample_user_source.py1. The data that sources return
A source returns plain objects, not Django models. The manager decides how they are stored, so a source needs no knowledge of the database. Add them to structures.py, below the enums:
from dataclasses import dataclass, field
from typing import Optional
@dataclass(frozen=True)
class RecipeDetails:
external_id: str
title: str
meal_type: MealType
instructions: str
servings: int
ingredients: list[str]
calories: float
@dataclass(frozen=True)
class ProfileDetails:
allergies: list[Allergen] = field(default_factory=list)
conditions: list[str] = field(default_factory=list)
dietary_preferences: list[str] = field(default_factory=list)
disliked_foods: list[str] = field(default_factory=list)
target_calories: Optional[int] = None
@dataclass(frozen=True)
class UserDetails:
email: str
first_name: str
last_name: str
password: str
profile: ProfileDetailsMove the imports to the top of the file. calories is per serving.
Allergens and dietary tags
When a recipe is imported, the domain definition computes its allergens and dietary tags from its ingredients, since these fields drive the retriever and the validator. This is domain code that Suvereo knows nothing about: a source could supply the labels itself, and the domain definition could compute them any other way. In this tutorial a classifier does it with keyword lists, in classification.py. It is small:
# diet_domain/classification.py
import re
from dataclasses import dataclass
from diet_domain.structures import Allergen, DietaryTag
# Heuristic keywords for matching ingredient names. The lists are short and can be extended.
ALLERGEN_KEYWORDS: dict[Allergen, tuple[str, ...]] = {
Allergen.GLUTEN: ("wheat", "flour", "bread", "pasta", "spaghetti", "noodle", "barley", "couscous", "tortilla"),
Allergen.DAIRY: ("milk", "cheese", "butter", "cream", "yogurt", "parmesan", "mozzarella", "feta"),
Allergen.EGGS: ("egg", "mayonnaise"),
Allergen.PEANUTS: ("peanut",),
Allergen.TREE_NUTS: ("almond", "walnut", "cashew", "pecan", "hazelnut", "pistachio"),
Allergen.SOY: ("soy", "tofu", "edamame", "miso"),
Allergen.FISH: ("fish", "salmon", "tuna", "cod", "anchovy", "anchovies", "mackerel"),
Allergen.SHELLFISH: ("prawn", "shrimp", "crab", "lobster", "mussel", "clam", "oyster"),
Allergen.SESAME: ("sesame", "tahini"),
}
MEAT_KEYWORDS = ("chicken", "beef", "pork", "lamb", "bacon", "ham", "sausage", "turkey", "duck", "mince")
@dataclass(frozen=True)
class Classification:
allergens: list[Allergen]
dietary_tags: list[DietaryTag]
class IngredientClassifier:
def __init__(self) -> None:
self._allergen_patterns = {
allergen: self._compile(keywords) for allergen, keywords in ALLERGEN_KEYWORDS.items()
}
self._meat_pattern = self._compile(MEAT_KEYWORDS)
def classify(self, ingredient_names: list[str]) -> Classification:
names = [name.lower() for name in ingredient_names]
allergens = [
allergen
for allergen, pattern in self._allergen_patterns.items()
if any(pattern.search(name) for name in names)
]
contains_meat = any(self._meat_pattern.search(name) for name in names)
vegetarian = not contains_meat and Allergen.FISH not in allergens and Allergen.SHELLFISH not in allergens
vegan = vegetarian and Allergen.DAIRY not in allergens and Allergen.EGGS not in allergens
dietary_tags = [
tag for tag, applies in ((DietaryTag.VEGETARIAN, vegetarian), (DietaryTag.VEGAN, vegan)) if applies
]
return Classification(allergens=allergens, dietary_tags=dietary_tags)
@staticmethod
def _compile(keywords: tuple[str, ...]) -> re.Pattern:
alternatives = "|".join(re.escape(keyword) for keyword in keywords)
return re.compile(rf"\b(?:{alternatives})(?:e?s)?\b")The keywords are keyed by Allergen, and the classifier returns enum members. The sync stores them as plain strings. A keyword match is a heuristic. It can label a dish wrongly, as the check at the end of this page shows. A domain definition whose output people rely on for their health would need a better source of this information.
2. The managers
A manager subclasses BaseSourceIntegrationManager. It receives an integration, calls fetch() and stores the result. Suvereo calls sync(plugin, dataset_id, integration_id) when a sync of an integration of the type is started, and gives the manager an indexer as self._indexer.
# diet_domain/sync/recipe.py
from typing import TYPE_CHECKING
from suvereo_common.indexing import BaseEntityIndexer
from suvereo_common.integrations import BaseSourceIntegrationManager
from diet_domain.classification import IngredientClassifier
from diet_domain.models import Recipe
if TYPE_CHECKING:
from diet_domain.integrations import RecipeSourcePlugin
class RecipeSourceManager(BaseSourceIntegrationManager):
def __init__(self, indexer: BaseEntityIndexer):
super().__init__(indexer)
self._classifier = IngredientClassifier()
def sync(self, plugin: "RecipeSourcePlugin", dataset_id: int, integration_id: int) -> None:
for details in plugin.fetch():
classification = self._classifier.classify(details.ingredients)
recipe, _ = Recipe.objects.update_or_create(
dataset_id=dataset_id,
external_id=details.external_id,
defaults={
"integration_id": integration_id,
"title": details.title,
"meal_type": details.meal_type.value,
"instructions": details.instructions,
"servings": details.servings,
"ingredients": details.ingredients,
"allergens": classification.allergens,
"dietary_tags": classification.dietary_tags,
"calories": details.calories,
},
)
self._indexer.index(recipe)update_or_createon(dataset, external_id)makes a sync repeatable. Running it again updates the recipes that exist and adds the new ones.self._indexer.index(recipe)is what makes the recipe searchable. It callsget_indexable_content()of the recipe, embeds the text and stores the chunks, as described on the previous page.
The user manager does more than store a record. A person in the diet domain definition is a Suvereo user, so the manager creates the account if it does not exist and grants the user the starter role that allows talking to agents:
# diet_domain/sync/user.py
from dataclasses import asdict
from typing import TYPE_CHECKING
from django.apps import apps
from django.contrib.auth import get_user_model
from suvereo_common.integrations import BaseSourceIntegrationManager
from diet_domain.models import UserProfile
if TYPE_CHECKING:
from diet_domain.integrations import UserSourcePlugin
CONVERSING_ROLE_NAME = "Agent Interaction"
class UserSourceManager(BaseSourceIntegrationManager):
def sync(self, plugin: "UserSourcePlugin", dataset_id: int, integration_id: int) -> None:
user_model = get_user_model()
for details in plugin.fetch():
user = user_model.objects.filter(email=details.email).first()
if user is None:
user = user_model.objects.create_user(
email=details.email,
password=details.password,
first_name=details.first_name,
last_name=details.last_name,
)
self._grant_conversing_role(user)
UserProfile.objects.update_or_create(
user=user,
defaults={"dataset_id": dataset_id, "integration_id": integration_id, **asdict(details.profile)},
)
@staticmethod
def _grant_conversing_role(user) -> None:
global_role_model = apps.get_model("account", "GlobalRole")
role = global_role_model.objects.filter(name=CONVERSING_ROLE_NAME).first()
if role is not None:
role.members.add(user)- An existing user keeps their password. The password in
detailsis used only when the account is created. - The domain definition looks up the
accountapp’s model by name, throughapps.get_model, rather than importing it, since a domain definition does not depend on the server’s code. - Agent Interaction is one of the starter roles created by
./manage.py seedroles. If the role does not exist, the user is created without it, and an admin can assign a role in the UI. See Users.
Export the managers:
# diet_domain/sync/__init__.py
from .recipe import RecipeSourceManager
from .user import UserSourceManager
__all__ = ["RecipeSourceManager", "UserSourceManager"]The managers refer to the integration types only in annotations, under TYPE_CHECKING, because the types in the next step import the managers.
3. The integration types
Each type subclasses BaseIntegration, points at its manager with INTEGRATION_MANAGER and declares the method its integrations implement. The names fetch, RecipeDetails and UserDetails belong to this domain definition. Suvereo does not define them.
# diet_domain/integrations.py
from abc import ABC, abstractmethod
from collections.abc import Iterable
from suvereo_common.integrations import BaseIntegration
from diet_domain.structures import RecipeDetails, UserDetails
from diet_domain.sync import RecipeSourceManager, UserSourceManager
class RecipeSourcePlugin(BaseIntegration, ABC):
INTEGRATION_MANAGER = RecipeSourceManager
INTEGRATION_TYPE_NAME = "Recipe source"
@abstractmethod
def fetch(self) -> Iterable[RecipeDetails]: ...
class UserSourcePlugin(BaseIntegration, ABC):
INTEGRATION_MANAGER = UserSourceManager
INTEGRATION_TYPE_NAME = "User source"
@abstractmethod
def fetch(self) -> Iterable[UserDetails]: ...INTEGRATION_TYPE_NAMEis the name shown in the UI. Each distinct name becomes an entry in the Add integration menu of a data set.INTEGRATION_MANAGERis the manager class itself. When a sync starts, Suvereo looks inCATALOG_INTEGRATION_MANAGERSfor a class that is a subclass of it, which the registered manager is. A type can point at an abstract manager instead, which lets other packages implement the type without depending on the manager’s code. A domain definition that keeps both in one package does not need that extra class.
4. The sample integrations
An integration implements a type for one external system. The two samples return fixed data, so the tutorial runs without an account anywhere. Each declares a TYPE, which identifies it in stored data, and a NAME, which the UI shows:
# diet_domain/sources/sample_recipe_source.py
from collections.abc import Iterable
from diet_domain.integrations import RecipeSourcePlugin
from diet_domain.structures import MealType, RecipeDetails
# (external id, title, meal type, calories per serving, ingredients)
SAMPLE_RECIPES = [
("b1", "Oatmeal with berries", MealType.BREAKFAST, 320, ["rolled oats", "milk", "blueberries", "honey"]),
("b2", "Vegan chia pudding", MealType.BREAKFAST, 280, ["chia seeds", "oat drink", "maple syrup", "strawberries"]),
("b3", "Scrambled eggs on toast", MealType.BREAKFAST, 410, ["eggs", "bread", "butter", "chives"]),
("m1", "Chicken and rice bowl", MealType.MAIN, 620, ["chicken breast", "rice", "broccoli", "olive oil"]),
("m2", "Peanut noodles", MealType.MAIN, 580, ["wheat noodles", "peanut butter", "soy sauce", "carrot", "lime"]),
("m3", "Lentil and spinach curry", MealType.MAIN, 540, ["red lentils", "spinach", "chopped tomatoes", "onion"]),
("m4", "Baked salmon with potatoes", MealType.MAIN, 690, ["salmon fillet", "potatoes", "lemon", "olive oil"]),
("s1", "Hummus with carrot sticks", MealType.SNACK, 180, ["chickpeas", "tahini", "lemon", "carrot"]),
("s2", "Greek yogurt with walnuts", MealType.SNACK, 230, ["greek yogurt", "walnuts", "honey"]),
]
class SampleRecipeSource(RecipeSourcePlugin):
TYPE = "sample-diet-recipes"
NAME = "Sample recipes"
def fetch(self) -> Iterable[RecipeDetails]:
for external_id, title, meal_type, calories, ingredients in SAMPLE_RECIPES:
yield RecipeDetails(
external_id=external_id,
title=title,
meal_type=meal_type,
instructions="",
servings=1,
ingredients=ingredients,
calories=calories,
)# diet_domain/sources/sample_user_source.py
from collections.abc import Iterable
from diet_domain.integrations import UserSourcePlugin
from diet_domain.structures import Allergen, ProfileDetails, UserDetails
SAMPLE_PASSWORD = "test123"
SAMPLE_USERS = [
(
"[email protected]",
"Anna",
"Kowalska",
ProfileDetails(
allergies=[Allergen.PEANUTS],
conditions=["type 2 diabetes"],
dietary_preferences=["low carb"],
disliked_foods=["mushrooms"],
target_calories=1700,
),
),
(
"[email protected]",
"Piotr",
"Nowak",
ProfileDetails(dietary_preferences=["vegan"], disliked_foods=["cilantro"], target_calories=2900),
),
(
"[email protected]",
"Maria",
"Wisniewska",
ProfileDetails(
allergies=[Allergen.GLUTEN, Allergen.DAIRY], conditions=["celiac disease"], target_calories=2100
),
),
]
class SampleUserSource(UserSourcePlugin):
TYPE = "sample-diet-users"
NAME = "Sample diet users"
def fetch(self) -> Iterable[UserDetails]:
for email, first_name, last_name, profile in SAMPLE_USERS:
yield UserDetails(
email=email, first_name=first_name, last_name=last_name, password=SAMPLE_PASSWORD, profile=profile
)# diet_domain/sources/__init__.py
from .sample_recipe_source import SampleRecipeSource
from .sample_user_source import SampleUserSource
__all__ = ["SampleRecipeSource", "SampleUserSource"]Every sample user gets the password test123. Use the samples only on an instance that is not exposed to other people.
5. Register the integrations
Integrations and managers are listed in server/config/settings_override.py. Types are not listed, since Suvereo finds them through the integrations that subclass them:
CATALOG_INTEGRATION_PLUGINS = [
"diet_domain.sources.SampleRecipeSource",
"diet_domain.sources.SampleUserSource",
]
CATALOG_INTEGRATION_MANAGERS = [
"diet_domain.sync.RecipeSourceManager",
"diet_domain.sync.UserSourceManager",
]Rebuild and restart with docker compose up -d --build.
Try it
- Open your data set and go to Integrations.
- Choose Add integration. The menu lists the two types, Recipe source and User source. Choose Recipe source and then Sample recipes.
- Click the Synchronize data button next to the new integration. The sync runs in the worker, and it needs the embedding provider of the data set, since each recipe is embedded.
- Repeat for User source and Sample diet users.
- Open Recipes and User profiles in the Catalog section of the sidebar. The recipes are listed with their calories and the allergens that the classifier found, and the three profiles with their allergies and targets.
Look at the allergens of “Peanut noodles”: gluten, dairy, peanuts and soy. The dairy comes from “peanut butter”, which the keyword butter matches. This is the limit of a keyword heuristic, and the reason the classifier is a separate class that can be replaced.
Running the sync again does not duplicate anything.
Connecting a real system
The samples return fixed data. An integration for a real system differs in two ways: it has settings that an admin enters in the UI, and fetch() calls the system. This section adds one: recipes from TheMealDB , a public recipe API. TheMealDB does not state calories or servings, so the integration looks calories up in the USDA FoodData Central database, which needs a free API key, and estimates the servings from the weight of the ingredients.
Nothing else in the domain definition changes. The integration yields RecipeDetails, and the recipe manager from step 2 stores it as it stored the sample recipes.
New files:
diet_domain/sources/
├── measures.py
├── usda.py
└── mealdb_recipe_source.pySettings
The settings are a Pydantic model that subclasses RequiredFieldsModel, set as CONFIGURATION_ARGS of the integration. Its fields become the form shown when the integration is added, and the entered values are available as self.CONFIGURATION_ARGS. This integration has two. The USDA key is required. The TheMealDB key defaults to 1, the free test key that TheMealDB publishes and that works for this tutorial. An admin who has their own key from TheMealDB can enter it in the form instead.
Measures
TheMealDB lists ingredients with free-text measures, such as 1 1/2 cups or 200g. To weigh them, convert a measure to grams:
# diet_domain/sources/measures.py
import re
from fractions import Fraction
from typing import Optional
QUANTITY_PATTERN = re.compile(r"^\s*(\d+\s+\d+/\d+|\d+/\d+|\d+(?:\.\d+)?)\s*([a-zA-Z]*)")
GRAMS_PER_UNIT: dict[str, float] = {
"g": 1,
"gram": 1,
"grams": 1,
"kg": 1000,
"kilo": 1000,
"mg": 0.001,
"ml": 1,
"l": 1000,
"litre": 1000,
"liter": 1000,
"cl": 10,
"dl": 100,
"cup": 240,
"cups": 240,
"tbsp": 15,
"tbs": 15,
"tblsp": 15,
"tablespoon": 15,
"tablespoons": 15,
"tsp": 5,
"teaspoon": 5,
"teaspoons": 5,
"oz": 28.35,
"ounce": 28.35,
"ounces": 28.35,
"lb": 453.6,
"lbs": 453.6,
"pound": 453.6,
"pounds": 453.6,
"pinch": 0.4,
"dash": 0.6,
"handful": 30,
"slice": 30,
"slices": 30,
"clove": 5,
"cloves": 5,
"can": 400,
"cans": 400,
"tin": 400,
"tins": 400,
}
DEFAULT_PIECE_GRAMS = 60.0
def parse_quantity(text: str) -> Optional[float]:
try:
return float(sum(Fraction(part) for part in text.split()))
except (ValueError, ZeroDivisionError):
return None
def measure_to_grams(measure: str) -> Optional[float]:
match = QUANTITY_PATTERN.match(measure)
if match is None:
return None
quantity = parse_quantity(match.group(1))
if quantity is None:
return None
unit = match.group(2).lower()
return quantity * GRAMS_PER_UNIT.get(unit, DEFAULT_PIECE_GRAMS)A measure without a quantity, such as to taste, gives None, and the ingredient is left out of the calorie sum.
Nutrition lookup
A small client asks FoodData Central for the energy of an ingredient per 100 g. It caches answers, since the same ingredients recur across recipes, and returns None when the lookup fails, so that one failed request skips an ingredient and not the whole sync:
# diet_domain/sources/usda.py
import logging
from typing import Optional
import requests
logger = logging.getLogger(__name__)
SEARCH_URL = "https://api.nal.usda.gov/fdc/v1/foods/search"
REQUEST_TIMEOUT_SECONDS = 20
# FoodData Central nutrient numbers for energy in kcal: 208 is the standard one, 957 and 958 are calculated
# with Atwater factors. A food reports whichever applies, so the first one present is used.
ENERGY_NUTRIENT_NUMBERS = ("208", "957", "958")
class FoodDataCentralClient:
def __init__(self, api_key: str):
self._api_key = api_key
self._cache: dict[str, Optional[float]] = {}
def calories_per_100g(self, ingredient_name: str) -> Optional[float]:
key = ingredient_name.strip().lower()
if key not in self._cache:
self._cache[key] = self._lookup(key)
return self._cache[key]
def _lookup(self, query: str) -> Optional[float]:
try:
response = requests.get(
SEARCH_URL,
params={"query": query, "dataType": "Foundation,SR Legacy", "pageSize": 1, "api_key": self._api_key},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
foods = response.json().get("foods", [])
except (requests.RequestException, ValueError):
logger.warning("FoodData Central lookup failed for '%s'", query, exc_info=True)
return None
return self._calories(foods[0].get("foodNutrients", [])) if foods else None
@staticmethod
def _calories(food_nutrients: list[dict]) -> Optional[float]:
values = {nutrient.get("nutrientNumber"): nutrient.get("value") for nutrient in food_nutrients}
return next((values[number] for number in ENERGY_NUTRIENT_NUMBERS if values.get(number) is not None), None)The integration
fetch() walks TheMealDB letter by letter, maps its categories to the meal types and turns each meal into RecipeDetails. It stops when it has returned max_recipes recipes, 50 by default:
# diet_domain/sources/mealdb_recipe_source.py
import logging
import string
from collections.abc import Iterable
from typing import Optional
import requests
from pydantic import Field
from suvereo_common.utils import RequiredFieldsModel
from diet_domain.integrations import RecipeSourcePlugin
from diet_domain.sources.measures import measure_to_grams
from diet_domain.sources.usda import FoodDataCentralClient
from diet_domain.structures import MealType, RecipeDetails
logger = logging.getLogger(__name__)
MEALDB_SEARCH_URL = "https://www.themealdb.com/api/json/v1/{api_key}/search.php"
REQUEST_TIMEOUT_SECONDS = 20
# TheMealDB lists up to 20 ingredient fields per meal.
MAX_INGREDIENTS_PER_MEAL = 20
# The share of ingredients whose calories must be known. Below it, the recipe's total would be misleading.
MIN_RESOLVED_INGREDIENTS_RATIO = 0.75
# Recipes in other TheMealDB categories (desserts, sauces) are skipped.
MEAL_TYPES = {
"Breakfast": MealType.BREAKFAST,
"Starter": MealType.SNACK,
"Side": MealType.SNACK,
**{
category: MealType.MAIN
for category in ("Beef", "Chicken", "Goat", "Lamb", "Pasta", "Pork", "Seafood", "Vegan", "Vegetarian")
},
}
# TheMealDB does not state how many people a recipe serves, so it is estimated from the weight of the ingredients.
PORTION_GRAMS = {MealType.BREAKFAST: 300, MealType.MAIN: 450, MealType.SNACK: 150}
# Upper limit of the estimate, so that a recipe of a few kilos is not read as dozens of servings.
MAX_SERVINGS = 12
class MealDbRecipeSourceConfig(RequiredFieldsModel):
mealdb_api_key: str = Field(
default="1",
title="TheMealDB API key",
description='The free test key "1" works. A key from https://www.themealdb.com/api.php can be used instead.',
)
usda_api_key: str = Field(
title="USDA API key",
description="FoodData Central API key, free at https://fdc.nal.usda.gov/api-key-signup",
)
max_recipes: int = Field(
default=50,
ge=1,
title="Maximum recipes",
description="The sync stops after this many recipes. Each ingredient needs a USDA request, so fewer is quicker.",
)
class MealDbRecipeSource(RecipeSourcePlugin):
TYPE = "mealdb-recipes"
NAME = "TheMealDB recipes with USDA nutrition"
CONFIGURATION_ARGS = MealDbRecipeSourceConfig
def fetch(self) -> Iterable[RecipeDetails]:
usda = FoodDataCentralClient(self.CONFIGURATION_ARGS.usda_api_key)
fetched = 0
for meal in self._meals(self.CONFIGURATION_ARGS.mealdb_api_key):
meal_type = MEAL_TYPES.get(meal.get("strCategory"))
if meal_type is None:
continue
recipe = self._to_recipe(meal, meal_type, usda)
if recipe is not None:
yield recipe
fetched += 1
if fetched >= self.CONFIGURATION_ARGS.max_recipes:
return
@staticmethod
def _meals(api_key: str) -> Iterable[dict]:
# TheMealDB has no "list all meals" endpoint. Searching by first letter is the documented way to walk
# the catalogue.
for letter in string.ascii_lowercase:
response = requests.get(
MEALDB_SEARCH_URL.format(api_key=api_key), params={"f": letter}, timeout=REQUEST_TIMEOUT_SECONDS
)
response.raise_for_status()
yield from response.json().get("meals") or []
@staticmethod
def _to_recipe(meal: dict, meal_type: MealType, usda: FoodDataCentralClient) -> Optional[RecipeDetails]:
ingredients = []
total_grams = total_calories = 0.0
resolved = 0
for index in range(1, MAX_INGREDIENTS_PER_MEAL + 1):
name = (meal.get(f"strIngredient{index}") or "").strip()
if not name:
continue
ingredients.append(name)
grams = measure_to_grams(meal.get(f"strMeasure{index}") or "")
calories_per_100g = usda.calories_per_100g(name) if grams is not None else None
if calories_per_100g is None:
continue
total_grams += grams
total_calories += calories_per_100g * grams / 100
resolved += 1
if not ingredients or resolved < MIN_RESOLVED_INGREDIENTS_RATIO * len(ingredients):
logger.info("Skipping '%s': too few ingredients with known calories", meal["strMeal"])
return None
servings = min(MAX_SERVINGS, max(1, round(total_grams / PORTION_GRAMS[meal_type])))
calories = round(total_calories / servings)
return RecipeDetails(
external_id=meal["idMeal"],
title=meal["strMeal"],
meal_type=meal_type,
instructions=meal.get("strInstructions") or "",
servings=servings,
ingredients=ingredients,
calories=calories,
)- Categories.
MEAL_TYPESmaps TheMealDB categories toMealType. Desserts and other categories are not in the map, so they are skipped. - Calories. The calories of a recipe are the sum over its ingredients of
calories per 100 g Ă— grams / 100. A recipe is skipped when fewer than 75% of its ingredients could be resolved. - Servings. The number of servings is the total weight divided by a typical portion for the meal type, between 1 and
MAX_SERVINGS. Calories per serving follow from it.
These are choices of this integration, not of Suvereo. A source that gets servings and nutrition from its API would be much shorter.
Export it next to the samples, in sources/__init__.py:
from .mealdb_recipe_source import MealDbRecipeSource
from .sample_recipe_source import SampleRecipeSource
from .sample_user_source import SampleUserSource
__all__ = ["MealDbRecipeSource", "SampleRecipeSource", "SampleUserSource"]Register and use it
CATALOG_INTEGRATION_PLUGINS = [
"diet_domain.sources.MealDbRecipeSource",
"diet_domain.sources.SampleRecipeSource",
"diet_domain.sources.SampleUserSource",
]Rebuild and restart with docker compose up -d --build, then add a Recipe source in the UI and choose TheMealDB recipes with USDA nutrition. The form has the TheMealDB key, already filled with 1, and the maximum number of recipes, already filled with 50, and asks for the USDA API key, which you can get at fdc.nal.usda.gov/api-key-signup . Start the sync as before. It makes a request for each distinct ingredient, so it takes a few minutes, and more if you raise the maximum. The requests dependency is already declared in the package’s pyproject.toml.
Several connections of one integration can exist on a data set by default. Set ALLOWS_MULTIPLE_INSTANCES = False on an integration that makes sense only once.
Continue with the agent.