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

1from fractions import Fraction 1e

2 

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

8 

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) 

20 

21from . import brute, openai 1e

22from ._base import ABCIngredientParser 1e

23from .parser_utils import extract_quantity_from_string 1e

24 

25logger = get_logger(__name__) 1e

26 

27 

28class BruteForceParser(ABCIngredientParser): 1e

29 """ 

30 Brute force ingredient parser. 

31 """ 

32 

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

34 bfi = brute.parse(ingredient_string, self) 1acb

35 

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 ) 

45 

46 matched_ingredient = self.find_ingredient_match(parsed_ingredient) 1acb

47 

48 qty_conf = 1 1acb

49 note_conf = 1 1acb

50 

51 unit_obj = matched_ingredient.ingredient.unit 1acb

52 food_obj = matched_ingredient.ingredient.food 1acb

53 

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

56 

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

58 

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 ) 

66 

67 return matched_ingredient 1acb

68 

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

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

71 

72 

73class NLPParser(ABCIngredientParser): 1e

74 """ 

75 Class for Ingredient Parser library 

76 """ 

77 

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 ) 

84 

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] 

88 

89 return ingredient_amount 1acb

90 

91 @staticmethod 1e

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

93 confidence = ingredient_amount.confidence 1dacb

94 

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 

103 

104 return qty, confidence 1dacb

105 

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

111 

112 @staticmethod 1e

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

114 if not ingredient.name: 1dacb

115 return "", 0 1dacb

116 

117 ingredient_name = ingredient.name[0] 1dacb

118 confidence = ingredient_name.confidence 1dacb

119 food = ingredient_name.text 1dacb

120 

121 return food, confidence 1dacb

122 

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

136 

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

141 

142 return note, confidence 1dacb

143 

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

150 

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

161 

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 ) 

179 

180 return self.find_ingredient_match(parsed_ingredient) 1dacb

181 

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

185 

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

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

188 

189 

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

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: 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