parsing.py (7849B)
1 """Quantity parsing, ingredient merging, and /addrecipe text parsing.""" 2 from __future__ import annotations 3 4 import re 5 from dataclasses import dataclass 6 from fractions import Fraction 7 8 # Rough keyword heuristic for keto auto-suggestion -- there's no nutrition 9 # database backing this, it's just a carb-keyword check. Always manually 10 # overridable via the "Keto:" field in /addrecipe. 11 HIGH_CARB_KEYWORDS = { 12 "sugar", "flour", "rice", "pasta", "bread", "potato", "corn", "oats", 13 "honey", "syrup", 14 } 15 16 17 def parse_quantity(raw: str | None) -> Fraction | None: 18 """Parse '1', '1/2', or '1 1/2' into a Fraction. 19 20 Returns None for anything that isn't a plain number/fraction (e.g. 21 "a pinch", "to taste") -- callers must treat those as non-mergeable 22 rather than guessing a value. 23 """ 24 if not raw: 25 return None 26 raw = raw.strip() 27 if not raw: 28 return None 29 30 total = Fraction(0) 31 matched_any = False 32 for part in raw.split(): 33 if re.fullmatch(r"\d+/\d+", part): 34 num, den = part.split("/") 35 total += Fraction(int(num), int(den)) 36 matched_any = True 37 elif re.fullmatch(r"\d+(\.\d+)?", part): 38 total += Fraction(part) 39 matched_any = True 40 else: 41 return None 42 return total if matched_any else None 43 44 45 def format_quantity(value: Fraction) -> str: 46 """Format a Fraction back to display form, e.g. Fraction(3, 2) -> '1 1/2'.""" 47 whole, rem = divmod(value.numerator, value.denominator) 48 if rem == 0: 49 return str(whole) 50 if whole == 0: 51 return f"{rem}/{value.denominator}" 52 return f"{whole} {rem}/{value.denominator}" 53 54 55 @dataclass 56 class Item: 57 name: str 58 quantity: str | None 59 unit: str | None 60 category: str | None 61 source: str # 'staple' | 'recipe' | 'freeform' 62 63 64 def merge_ingredients(items: list[Item]) -> list[Item]: 65 """Merge items by (normalized name, unit). 66 67 Matching name+unit pairs have their quantities summed when both parse 68 as numbers. Mismatched units, or quantities that don't parse (e.g. "a 69 pinch"), are kept as separate line items rather than guessing a 70 conversion -- per CLAUDE.md's merge rules. 71 """ 72 merged: dict[tuple[str, str | None], Item] = {} 73 passthrough: list[Item] = [] 74 75 for item in items: 76 key_name = item.name.strip().lower() 77 key_unit = (item.unit or "").strip().lower() or None 78 qty = parse_quantity(item.quantity) 79 80 if qty is None: 81 passthrough.append(item) 82 continue 83 84 key = (key_name, key_unit) 85 if key in merged: 86 existing = merged[key] 87 existing_qty = parse_quantity(existing.quantity) 88 merged[key] = Item( 89 name=existing.name, 90 quantity=format_quantity(existing_qty + qty), 91 unit=existing.unit, 92 category=existing.category, 93 source=existing.source, 94 ) 95 else: 96 merged[key] = item 97 98 return list(merged.values()) + passthrough 99 100 101 def group_by_category(items: list[Item]) -> list[tuple[str | None, list[Item]]]: 102 """Group items by category, preserving first-seen category order. 103 104 Items with no category (None) are grouped together and always listed 105 last -- no attempt is made to guess a category from the ingredient 106 name, so anything not explicitly categorized just lands in one 107 leftover group instead of being sorted (or mis-sorted) automatically. 108 """ 109 groups: dict[str | None, list[Item]] = {} 110 for item in items: 111 groups.setdefault(item.category, []).append(item) 112 113 ordered = [(cat, group) for cat, group in groups.items() if cat is not None] 114 if None in groups: 115 ordered.append((None, groups[None])) 116 return ordered 117 118 119 def suggest_keto(ingredient_names: list[str]) -> bool: 120 """Flag False if any ingredient name contains a high-carb keyword.""" 121 for name in ingredient_names: 122 lname = name.lower() 123 if any(keyword in lname for keyword in HIGH_CARB_KEYWORDS): 124 return False 125 return True 126 127 128 SECTION_HEADERS = {"ingredients", "instructions", "nutrition"} 129 130 131 def parse_recipe_text(name: str, body: str) -> dict: 132 """Parse a recipe body pasted as free-form text (e.g. copied straight 133 from a recipe blog) into ingredients/instructions. 134 135 The recipe name is passed in separately rather than parsed out of the 136 body -- real copy-pasted recipes usually don't include the page title 137 in the copied text, so there's nothing reliable to extract it from. 138 139 Section headers ("Ingredients", "Instructions", "Nutrition") are 140 matched case-insensitively with or without a trailing colon. Before 141 the first recognized header, lines default to the "ingredients" 142 section, since most pasted recipes list ingredients first, unlabeled. 143 A "Nutrition" section, if present, is recognized and discarded (out 144 of scope per CLAUDE.md). 145 146 Ingredient lines containing "|" are parsed as 147 "<quantity> | <unit> | <name> | <category>" for precise manual entry. 148 Lines without "|" are kept whole as the ingredient name (quantity/ 149 unit/category left unset) -- there's no reliable way to split "2 150 medium yellow onions, very finely chopped" into quantity/unit/name 151 without a real NLP ingredient parser, so that's not attempted; those 152 items just won't auto-merge by quantity later. 153 154 Optional metadata lines, matched anywhere in the body: 155 Servings: <n> 156 Source: <text or url> 157 Keto: yes|no (omit to auto-suggest from ingredient names) 158 """ 159 name = name.strip() 160 if not name: 161 raise ValueError("a recipe name is required") 162 163 fields = {"servings": None, "source": None, "is_keto": None} 164 ingredients: list[dict] = [] 165 instructions_lines: list[str] = [] 166 section = "ingredients" 167 168 for line in body.splitlines(): 169 stripped = line.strip() 170 if not stripped: 171 continue 172 173 header = stripped.rstrip(":").lower() 174 if header in SECTION_HEADERS: 175 section = header 176 continue 177 178 lower = stripped.lower() 179 if lower.startswith("servings:"): 180 value = stripped.split(":", 1)[1].strip() 181 fields["servings"] = int(value) if value.isdigit() else None 182 continue 183 if lower.startswith("source:"): 184 fields["source"] = stripped.split(":", 1)[1].strip() 185 continue 186 if lower.startswith("keto:"): 187 value = stripped.split(":", 1)[1].strip().lower() 188 fields["is_keto"] = value in ("yes", "true", "1") 189 continue 190 191 if section == "ingredients": 192 if "|" in stripped: 193 parts = [p.strip() for p in stripped.split("|")] 194 parts += [None] * (4 - len(parts)) 195 quantity, unit, ing_name, category = parts[:4] 196 ingredients.append({ 197 "quantity": quantity or None, 198 "unit": unit or None, 199 "name": ing_name, 200 "category": category or None, 201 }) 202 else: 203 ingredients.append({ 204 "quantity": None, "unit": None, 205 "name": stripped, "category": None, 206 }) 207 elif section == "instructions": 208 instructions_lines.append(stripped) 209 # section == "nutrition": intentionally discarded 210 211 is_keto = fields["is_keto"] 212 if is_keto is None: 213 is_keto = suggest_keto([i["name"] for i in ingredients if i["name"]]) 214 215 return { 216 "name": name, 217 "servings": fields["servings"], 218 "source": fields["source"], 219 "is_keto": is_keto, 220 "ingredients": ingredients, 221 "instructions": "\n".join(instructions_lines), 222 }