Coverage for opt/mealie/lib/python3.12/site-packages/mealie/services/parser_services/ingredient_parser.py: 88%

104 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-25 15:48 +0000

1from fractions import Fraction 1b

2 

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

8 

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) 

20 

21from . import brute, openai 1b

22from ._base import ABCIngredientParser 1b

23from .parser_utils import extract_quantity_from_string 1b

24 

25logger = get_logger(__name__) 1b

26 

27 

28class BruteForceParser(ABCIngredientParser): 1b

29 """ 

30 Brute force ingredient parser. 

31 """ 

32 

33 async def parse_one(self, ingredient_string: str) -> ParsedIngredient: 1b

34 bfi = brute.parse(ingredient_string, self) 

35 

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 ) 

45 

46 matched_ingredient = self.find_ingredient_match(parsed_ingredient) 

47 

48 qty_conf = 1 

49 note_conf = 1 

50 

51 unit_obj = matched_ingredient.ingredient.unit 

52 food_obj = matched_ingredient.ingredient.food 

53 

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 

56 

57 avg_conf = (qty_conf + unit_conf + food_conf + note_conf) / 4 

58 

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 ) 

66 

67 return matched_ingredient 

68 

69 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1b

70 return [await self.parse_one(ingredient) for ingredient in ingredients] 

71 

72 

73class NLPParser(ABCIngredientParser): 1b

74 """ 

75 Class for Ingredient Parser library 

76 """ 

77 

78 @staticmethod 1b

79 def _extract_amount(ingredient: IngredientParserParsedIngredient) -> IngredientAmount: 1b

80 if not (ingredient_amounts := ingredient.amount): 

81 return IngredientAmount( 

82 quantity=Fraction(0), quantity_max=Fraction(0), unit="", text="", confidence=0, starting_index=-1 

83 ) 

84 

85 ingredient_amount = ingredient_amounts[0] 

86 if isinstance(ingredient_amount, CompositeIngredientAmount): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 ingredient_amount = ingredient_amount.amounts[0] 

88 

89 return ingredient_amount 

90 

91 @staticmethod 1b

92 def _extract_quantity(ingredient_amount: IngredientAmount) -> tuple[float, float]: 1b

93 confidence = ingredient_amount.confidence 

94 

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 

103 

104 return qty, confidence 

105 

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 

111 

112 @staticmethod 1b

113 def _extract_food(ingredient: IngredientParserParsedIngredient) -> tuple[str, float]: 1b

114 if not ingredient.name: 

115 return "", 0 

116 

117 ingredient_name = ingredient.name[0] 

118 confidence = ingredient_name.confidence 

119 food = ingredient_name.text 

120 

121 return food, confidence 

122 

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: 

134 note_parts.append(ingredient.comment.text) 

135 confidences.append(ingredient.comment.confidence) 

136 

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(")", "") 

141 

142 return note, confidence 

143 

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) 

150 

151 # average confidence for components which were parsed 

152 confidences: list[float] = [] 

153 if qty: 

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: 

160 confidences.append(note_conf) 

161 

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 ) 

179 

180 return self.find_ingredient_match(parsed_ingredient) 

181 

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) 

185 

186 async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: 1b

187 return [await self.parse_one(ingredient) for ingredient in ingredients] 

188 

189 

190__registrar: dict[RegisteredParser, type[ABCIngredientParser]] = { 1b

191 RegisteredParser.nlp: NLPParser, 

192 RegisteredParser.brute: BruteForceParser, 

193 RegisteredParser.openai: openai.OpenAIParser, 

194} 

195 

196 

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)