grocery-bot

Log | Files | Refs | README

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     }