Coverage for opt/mealie/lib/python3.12/site-packages/mealie/services/parser_services/ingredient_parser.py: 94%
104 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:32 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:32 +0000
1from fractions import Fraction 1e
3from ingredient_parser import parse_ingredient 1e
4from ingredient_parser.dataclasses import CompositeIngredientAmount, IngredientAmount 1e
5from ingredient_parser.dataclasses import ParsedIngredient as IngredientParserParsedIngredient 1e
6from pydantic import UUID4 1e
7from sqlalchemy.orm import Session 1e
9from mealie.core.root_logger import get_logger 1e
10from mealie.schema.recipe import RecipeIngredient 1e
11from mealie.schema.recipe.recipe_ingredient import ( 1e
12 CreateIngredientFood,
13 CreateIngredientUnit,
14 IngredientConfidence,
15 IngredientFood,
16 IngredientUnit,
17 ParsedIngredient,
18 RegisteredParser,
19)
21from . import brute, openai 1e
22from ._base import ABCIngredientParser 1e
23from .parser_utils import extract_quantity_from_string 1e
25logger = get_logger(__name__) 1e
28class BruteForceParser(ABCIngredientParser): 1e
29 """
30 Brute force ingredient parser.
31 """
33 async def parse_one(self, ingredient_string: str) -> ParsedIngredient: 1e
34 bfi = brute.parse(ingredient_string, self) 1bca
36 parsed_ingredient = ParsedIngredient( 1bca
37 input=ingredient_string,
38 ingredient=RecipeIngredient(
39 unit=CreateIngredientUnit(name=bfi.unit),
40 food=CreateIngredientFood(name=bfi.food),
41 quantity=bfi.amount,
42 note=bfi.note,
43 ),
44 )
46 matched_ingredient = self.find_ingredient_match(parsed_ingredient) 1bca
48 qty_conf = 1 1bca
49 note_conf = 1 1bca
51 unit_obj = matched_ingredient.ingredient.unit 1bca
52 food_obj = matched_ingredient.ingredient.food 1bca
54 unit_conf = 1 if bfi.unit is None or isinstance(unit_obj, IngredientUnit) else 0 1bca
55 food_conf = 1 if bfi.food is None or isinstance(food_obj, IngredientFood) else 0 1bca
57 avg_conf = (qty_conf + unit_conf + food_conf + note_conf) / 4 1bca
59 matched_ingredient.confidence = IngredientConfidence( 1bca
60 average=avg_conf,
61 quantity=qty_conf,
62 unit=unit_conf,
63 food=food_conf,
64 comment=note_conf,
65 )
67 return matched_ingredient 1bca
69 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1e
70 return [await self.parse_one(ingredient) for ingredient in ingredients] 1bca
73class NLPParser(ABCIngredientParser): 1e
74 """
75 Class for Ingredient Parser library
76 """
78 @staticmethod 1e
79 def _extract_amount(ingredient: IngredientParserParsedIngredient) -> IngredientAmount: 1e
80 if not (ingredient_amounts := ingredient.amount): 1dbca
81 return IngredientAmount( 1dbca
82 quantity=Fraction(0), quantity_max=Fraction(0), unit="", text="", confidence=0, starting_index=-1
83 )
85 ingredient_amount = ingredient_amounts[0] 1bca
86 if isinstance(ingredient_amount, CompositeIngredientAmount): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true1bca
87 ingredient_amount = ingredient_amount.amounts[0]
89 return ingredient_amount 1bca
91 @staticmethod 1e
92 def _extract_quantity(ingredient_amount: IngredientAmount) -> tuple[float, float]: 1e
93 confidence = ingredient_amount.confidence 1dbca
95 if isinstance(ingredient_amount.quantity, str): 1dbca
96 qty = extract_quantity_from_string(ingredient_amount.quantity)[0] 1bca
97 else:
98 try: 1dbca
99 qty = float(ingredient_amount.quantity) 1dbca
100 except ValueError:
101 qty = 0
102 confidence = 0
104 return qty, confidence 1dbca
106 @staticmethod 1e
107 def _extract_unit(ingredient_amount: IngredientAmount) -> tuple[str, float]: 1e
108 confidence = ingredient_amount.confidence 1dbca
109 unit = str(ingredient_amount.unit) if ingredient_amount.unit else "" 1dbca
110 return unit, confidence 1dbca
112 @staticmethod 1e
113 def _extract_food(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1e
114 if not ingredient.name: 1dbca
115 return "", 0 1dbca
117 ingredient_name = ingredient.name[0] 1dbca
118 confidence = ingredient_name.confidence 1dbca
119 food = ingredient_name.text 1dbca
121 return food, confidence 1dbca
123 @staticmethod 1e
124 def _extract_note(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1e
125 confidences: list[float] = [] 1dbca
126 note_parts: list[str] = [] 1dbca
127 if ingredient.size: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true1dbca
128 note_parts.append(ingredient.size.text)
129 confidences.append(ingredient.size.confidence)
130 if ingredient.preparation: 1dbca
131 note_parts.append(ingredient.preparation.text)
132 confidences.append(ingredient.preparation.confidence)
133 if ingredient.comment: 1dbca
134 note_parts.append(ingredient.comment.text) 1bca
135 confidences.append(ingredient.comment.confidence) 1bca
137 # average confidence among all note parts
138 confidence = sum(confidences) / len(confidences) if confidences else 0 1dbca
139 note = ", ".join(note_parts) 1dbca
140 note = note.replace("(", "").replace(")", "") 1dbca
142 return note, confidence 1dbca
144 def _convert_ingredient(self, ingredient: IngredientParserParsedIngredient) -> ParsedIngredient: 1e
145 ingredient_amount = self._extract_amount(ingredient) 1dbca
146 qty, qty_conf = self._extract_quantity(ingredient_amount) 1dbca
147 unit, unit_conf = self._extract_unit(ingredient_amount) 1dbca
148 food, food_conf = self._extract_food(ingredient) 1dbca
149 note, note_conf = self._extract_note(ingredient) 1dbca
151 # average confidence for components which were parsed
152 confidences: list[float] = [] 1dbca
153 if qty: 1dbca
154 confidences.append(qty_conf) 1bca
155 if unit: 1dbca
156 confidences.append(unit_conf) 1bca
157 if food: 1dbca
158 confidences.append(food_conf) 1dbca
159 if note: 1dbca
160 confidences.append(note_conf) 1bca
162 parsed_ingredient = ParsedIngredient( 1dbca
163 input=ingredient.sentence,
164 confidence=IngredientConfidence(
165 average=(sum(confidences) / len(confidences)) if confidences else 0,
166 quantity=qty_conf,
167 unit=unit_conf,
168 food=food_conf,
169 comment=note_conf,
170 ),
171 ingredient=RecipeIngredient(
172 title="",
173 quantity=qty,
174 unit=CreateIngredientUnit(name=unit) if unit else None,
175 food=CreateIngredientFood(name=food) if food else None,
176 note=note,
177 ),
178 )
180 return self.find_ingredient_match(parsed_ingredient) 1dbca
182 async def parse_one(self, ingredient_string: str) -> ParsedIngredient: 1e
183 parsed_ingredient = parse_ingredient(ingredient_string) 1dbca
184 return self._convert_ingredient(parsed_ingredient) 1dbca
186 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1e
187 return [await self.parse_one(ingredient) for ingredient in ingredients] 1dbca
190__registrar: dict[RegisteredParser, type[ABCIngredientParser]] = { 1e
191 RegisteredParser.nlp: NLPParser,
192 RegisteredParser.brute: BruteForceParser,
193 RegisteredParser.openai: openai.OpenAIParser,
194}
197def get_parser(parser: RegisteredParser, group_id: UUID4, session: Session) -> ABCIngredientParser: 1e
198 """
199 get_parser returns an ingrdeint parser based on the string enum value
200 passed in.
201 """
202 return __registrar.get(parser, NLPParser)(group_id, session) 1dbca