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-11-25 17:29 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 17:29 +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) 1acb
36 parsed_ingredient = ParsedIngredient( 1acb
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) 1acb
48 qty_conf = 1 1acb
49 note_conf = 1 1acb
51 unit_obj = matched_ingredient.ingredient.unit 1acb
52 food_obj = matched_ingredient.ingredient.food 1acb
54 unit_conf = 1 if bfi.unit is None or isinstance(unit_obj, IngredientUnit) else 0 1acb
55 food_conf = 1 if bfi.food is None or isinstance(food_obj, IngredientFood) else 0 1acb
57 avg_conf = (qty_conf + unit_conf + food_conf + note_conf) / 4 1acb
59 matched_ingredient.confidence = IngredientConfidence( 1acb
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 1acb
69 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1e
70 return [await self.parse_one(ingredient) for ingredient in ingredients] 1acb
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): 1dacb
81 return IngredientAmount( 1dacb
82 quantity=Fraction(0), quantity_max=Fraction(0), unit="", text="", confidence=0, starting_index=-1
83 )
85 ingredient_amount = ingredient_amounts[0] 1acb
86 if isinstance(ingredient_amount, CompositeIngredientAmount): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true1acb
87 ingredient_amount = ingredient_amount.amounts[0]
89 return ingredient_amount 1acb
91 @staticmethod 1e
92 def _extract_quantity(ingredient_amount: IngredientAmount) -> tuple[float, float]: 1e
93 confidence = ingredient_amount.confidence 1dacb
95 if isinstance(ingredient_amount.quantity, str): 1dacb
96 qty = extract_quantity_from_string(ingredient_amount.quantity)[0] 1acb
97 else:
98 try: 1dacb
99 qty = float(ingredient_amount.quantity) 1dacb
100 except ValueError:
101 qty = 0
102 confidence = 0
104 return qty, confidence 1dacb
106 @staticmethod 1e
107 def _extract_unit(ingredient_amount: IngredientAmount) -> tuple[str, float]: 1e
108 confidence = ingredient_amount.confidence 1dacb
109 unit = str(ingredient_amount.unit) if ingredient_amount.unit else "" 1dacb
110 return unit, confidence 1dacb
112 @staticmethod 1e
113 def _extract_food(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1e
114 if not ingredient.name: 1dacb
115 return "", 0 1dacb
117 ingredient_name = ingredient.name[0] 1dacb
118 confidence = ingredient_name.confidence 1dacb
119 food = ingredient_name.text 1dacb
121 return food, confidence 1dacb
123 @staticmethod 1e
124 def _extract_note(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1e
125 confidences: list[float] = [] 1dacb
126 note_parts: list[str] = [] 1dacb
127 if ingredient.size: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true1dacb
128 note_parts.append(ingredient.size.text)
129 confidences.append(ingredient.size.confidence)
130 if ingredient.preparation: 1dacb
131 note_parts.append(ingredient.preparation.text) 1ab
132 confidences.append(ingredient.preparation.confidence) 1ab
133 if ingredient.comment: 1dacb
134 note_parts.append(ingredient.comment.text) 1acb
135 confidences.append(ingredient.comment.confidence) 1acb
137 # average confidence among all note parts
138 confidence = sum(confidences) / len(confidences) if confidences else 0 1dacb
139 note = ", ".join(note_parts) 1dacb
140 note = note.replace("(", "").replace(")", "") 1dacb
142 return note, confidence 1dacb
144 def _convert_ingredient(self, ingredient: IngredientParserParsedIngredient) -> ParsedIngredient: 1e
145 ingredient_amount = self._extract_amount(ingredient) 1dacb
146 qty, qty_conf = self._extract_quantity(ingredient_amount) 1dacb
147 unit, unit_conf = self._extract_unit(ingredient_amount) 1dacb
148 food, food_conf = self._extract_food(ingredient) 1dacb
149 note, note_conf = self._extract_note(ingredient) 1dacb
151 # average confidence for components which were parsed
152 confidences: list[float] = [] 1dacb
153 if qty: 1dacb
154 confidences.append(qty_conf) 1acb
155 if unit: 1dacb
156 confidences.append(unit_conf) 1acb
157 if food: 1dacb
158 confidences.append(food_conf) 1dacb
159 if note: 1dacb
160 confidences.append(note_conf) 1acb
162 parsed_ingredient = ParsedIngredient( 1dacb
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) 1dacb
182 async def parse_one(self, ingredient_string: str) -> ParsedIngredient: 1e
183 parsed_ingredient = parse_ingredient(ingredient_string) 1dacb
184 return self._convert_ingredient(parsed_ingredient) 1dacb
186 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1e
187 return [await self.parse_one(ingredient) for ingredient in ingredients] 1dacb
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) 1dacb