Coverage for opt/mealie/lib/python3.12/site-packages/mealie/services/parser_services/ingredient_parser.py: 79%
104 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:32 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:32 +0000
1from fractions import Fraction 1b
3from ingredient_parser import parse_ingredient 1b
4from ingredient_parser.dataclasses import CompositeIngredientAmount, IngredientAmount 1b
5from ingredient_parser.dataclasses import ParsedIngredient as IngredientParserParsedIngredient 1b
6from pydantic import UUID4 1b
7from sqlalchemy.orm import Session 1b
9from mealie.core.root_logger import get_logger 1b
10from mealie.schema.recipe import RecipeIngredient 1b
11from mealie.schema.recipe.recipe_ingredient import ( 1b
12 CreateIngredientFood,
13 CreateIngredientUnit,
14 IngredientConfidence,
15 IngredientFood,
16 IngredientUnit,
17 ParsedIngredient,
18 RegisteredParser,
19)
21from . import brute, openai 1b
22from ._base import ABCIngredientParser 1b
23from .parser_utils import extract_quantity_from_string 1b
25logger = get_logger(__name__) 1b
28class BruteForceParser(ABCIngredientParser): 1b
29 """
30 Brute force ingredient parser.
31 """
33 async def parse_one(self, ingredient_string: str) -> ParsedIngredient: 1b
34 bfi = brute.parse(ingredient_string, self)
36 parsed_ingredient = ParsedIngredient(
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)
48 qty_conf = 1
49 note_conf = 1
51 unit_obj = matched_ingredient.ingredient.unit
52 food_obj = matched_ingredient.ingredient.food
54 unit_conf = 1 if bfi.unit is None or isinstance(unit_obj, IngredientUnit) else 0
55 food_conf = 1 if bfi.food is None or isinstance(food_obj, IngredientFood) else 0
57 avg_conf = (qty_conf + unit_conf + food_conf + note_conf) / 4
59 matched_ingredient.confidence = IngredientConfidence(
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
69 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1b
70 return [await self.parse_one(ingredient) for ingredient in ingredients]
73class NLPParser(ABCIngredientParser): 1b
74 """
75 Class for Ingredient Parser library
76 """
78 @staticmethod 1b
79 def _extract_amount(ingredient: IngredientParserParsedIngredient) -> IngredientAmount: 1b
80 if not (ingredient_amounts := ingredient.amount): 80 ↛ 85line 80 didn't jump to line 85 because the condition on line 80 was always true
81 return IngredientAmount(
82 quantity=Fraction(0), quantity_max=Fraction(0), unit="", text="", confidence=0, starting_index=-1
83 )
85 ingredient_amount = ingredient_amounts[0]
86 if isinstance(ingredient_amount, CompositeIngredientAmount):
87 ingredient_amount = ingredient_amount.amounts[0]
89 return ingredient_amount
91 @staticmethod 1b
92 def _extract_quantity(ingredient_amount: IngredientAmount) -> tuple[float, float]: 1b
93 confidence = ingredient_amount.confidence
95 if isinstance(ingredient_amount.quantity, str): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 qty = extract_quantity_from_string(ingredient_amount.quantity)[0]
97 else:
98 try:
99 qty = float(ingredient_amount.quantity)
100 except ValueError:
101 qty = 0
102 confidence = 0
104 return qty, confidence
106 @staticmethod 1b
107 def _extract_unit(ingredient_amount: IngredientAmount) -> tuple[str, float]: 1b
108 confidence = ingredient_amount.confidence
109 unit = str(ingredient_amount.unit) if ingredient_amount.unit else ""
110 return unit, confidence
112 @staticmethod 1b
113 def _extract_food(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1b
114 if not ingredient.name:
115 return "", 0
117 ingredient_name = ingredient.name[0]
118 confidence = ingredient_name.confidence
119 food = ingredient_name.text
121 return food, confidence
123 @staticmethod 1b
124 def _extract_note(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1b
125 confidences: list[float] = []
126 note_parts: list[str] = []
127 if ingredient.size: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 note_parts.append(ingredient.size.text)
129 confidences.append(ingredient.size.confidence)
130 if ingredient.preparation: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 note_parts.append(ingredient.preparation.text)
132 confidences.append(ingredient.preparation.confidence)
133 if ingredient.comment: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 note_parts.append(ingredient.comment.text)
135 confidences.append(ingredient.comment.confidence)
137 # average confidence among all note parts
138 confidence = sum(confidences) / len(confidences) if confidences else 0
139 note = ", ".join(note_parts)
140 note = note.replace("(", "").replace(")", "")
142 return note, confidence
144 def _convert_ingredient(self, ingredient: IngredientParserParsedIngredient) -> ParsedIngredient: 1b
145 ingredient_amount = self._extract_amount(ingredient)
146 qty, qty_conf = self._extract_quantity(ingredient_amount)
147 unit, unit_conf = self._extract_unit(ingredient_amount)
148 food, food_conf = self._extract_food(ingredient)
149 note, note_conf = self._extract_note(ingredient)
151 # average confidence for components which were parsed
152 confidences: list[float] = []
153 if qty: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 confidences.append(qty_conf)
155 if unit: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true
156 confidences.append(unit_conf)
157 if food:
158 confidences.append(food_conf)
159 if note: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 confidences.append(note_conf)
162 parsed_ingredient = ParsedIngredient(
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)
182 async def parse_one(self, ingredient_string: str) -> ParsedIngredient: 1b
183 parsed_ingredient = parse_ingredient(ingredient_string)
184 return self._convert_ingredient(parsed_ingredient)
186 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1b
187 return [await self.parse_one(ingredient) for ingredient in ingredients]
190__registrar: dict[RegisteredParser, type[ABCIngredientParser]] = { 1b
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: 1b
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)