Spaces:
Sleeping
Sleeping
| import re | |
| from typing import Dict, Any, Optional | |
| # Words that describe preparation, not the ingredient itself — stripped from name | |
| _PREP_WORDS = re.compile( | |
| r"^(chopped|minced|diced|grated|sliced|crushed|ground|peeled|toasted|roasted|" | |
| r"fresh|freshly|frozen|canned|organic|whole|small|large|medium|" | |
| r"optional|divided|cloves?|" | |
| r"cups?|tablespoons?|tbsp|teaspoons?|tsp|grams?|g|kg|ml|liters?|l|" | |
| r"pinch|sprinkle|dash|drop|inch(?:es)?|pieces?|sticks?|slices?)\s+", | |
| re.IGNORECASE | |
| ) | |
| # Unit words that can follow conversational quantities ("to taste", "for garnish") | |
| _CONVERSATIONAL_UNITS = {"pinch", "dash", "drop", "sprinkle"} | |
| class QuantityParserTool: | |
| """Tool to parse ingredients and extract quantity information""" | |
| def get_tool_definition() -> Dict[str, Any]: | |
| """Returns the tool definition for Groq function calling""" | |
| return { | |
| "type": "function", | |
| "function": { | |
| "name": "parse_ingredient_quantity", | |
| "description": "Parse an ingredient string to extract quantity, unit, and ingredient name. Handles various formats like '1 cup flour', '2 tbsp butter', '3 eggs', etc.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "ingredient_string": { | |
| "type": "string", | |
| "description": "The ingredient string to parse (e.g., '2 cups flour', '1 tablespoon olive oil')" | |
| } | |
| }, | |
| "required": ["ingredient_string"] | |
| } | |
| } | |
| } | |
| def _strip_prep_words(name: str) -> str: | |
| """Remove leading preparation descriptors (chopped, fresh, etc.) from name""" | |
| while True: | |
| cleaned = _PREP_WORDS.sub("", name).strip() | |
| if cleaned == name: | |
| break | |
| name = cleaned | |
| return name.strip("-, ") | |
| def _handle_conversational_phrases(ingredient: str, raw_input: str) -> Optional[Dict[str, Any]]: | |
| """Handle non-numeric conversational phrases like 'to taste', 'for garnish', 'pinch of'""" | |
| ingredient_lower = ingredient.lower() | |
| # "to taste" — can appear at start or end ("to taste Pepper" / "Salt to taste") | |
| if "to taste" in ingredient_lower: | |
| name = re.sub(r"\bto taste\b", "", ingredient, flags=re.IGNORECASE).strip("-, ") | |
| unit, name = QuantityParserTool._extract_conversational_unit(name) | |
| return { | |
| "quantity": "to taste", | |
| "unit": unit, | |
| "name": QuantityParserTool._strip_prep_words(name), | |
| "raw_input": raw_input | |
| } | |
| # "as needed" — "as needed Spices", "as needed Salt" | |
| if "as needed" in ingredient_lower[:20]: | |
| name = re.sub(r"\bas needed\b", "", ingredient, flags=re.IGNORECASE).strip("-, ") | |
| unit, name = QuantityParserTool._extract_conversational_unit(name) | |
| return { | |
| "quantity": "as needed", | |
| "unit": unit, | |
| "name": QuantityParserTool._strip_prep_words(name), | |
| "raw_input": raw_input | |
| } | |
| # "for garnish" → "Fresh Cilantro for garnish" | |
| if "for garnish" in ingredient_lower: | |
| name = re.sub(r"\bfor garnish\b", "", ingredient, flags=re.IGNORECASE).strip("-, ") | |
| unit, name = QuantityParserTool._extract_conversational_unit(name) | |
| return { | |
| "quantity": "for garnish", | |
| "unit": unit, | |
| "name": QuantityParserTool._strip_prep_words(name), | |
| "raw_input": raw_input | |
| } | |
| # "a pinch of ..." / "pinch of ..." / "1 pinch of ..." / "2 pinches of ..." | |
| pinch_match = re.match(r"^(\d+\.?\d*)?\s*(a\s+)?pinch(?:es)?\s+of\s+(.+)", ingredient, re.IGNORECASE) | |
| if pinch_match: | |
| quantity = pinch_match.group(1) or "1" | |
| name = pinch_match.group(3).strip() | |
| return { | |
| "quantity": quantity.strip(), | |
| "unit": "pinch", | |
| "name": QuantityParserTool._strip_prep_words(name), | |
| "raw_input": raw_input | |
| } | |
| return None | |
| def _extract_conversational_unit(text: str) -> tuple: | |
| """After stripping a conversational phrase, check if the remainder starts | |
| with a known unit word (e.g. 'pinch', 'dash') and extract it.""" | |
| text = text.strip() | |
| for word in sorted(_CONVERSATIONAL_UNITS, key=len, reverse=True): | |
| if text.lower().startswith(word) and (len(text) == len(word) or not text[len(word)].isalpha()): | |
| return word, text[len(word):].strip("-, ") | |
| return "", text | |
| def _post_process(result: Dict[str, Any]) -> Dict[str, Any]: | |
| """Clean up residual noise: trailing dashes on quantities and duplicate word leaks in names.""" | |
| # Clean trailing dashes from quantity strings (e.g. "1-" → "1") | |
| qty = result.get("quantity", "") | |
| if qty.endswith("-"): | |
| qty = qty.rstrip("-").strip() | |
| result["quantity"] = qty | |
| # De-duplicate name words where one is a case-insensitive substring of another | |
| # e.g. "onion Onions" → "Onions", "Salt salt" → "Salt" | |
| name = result.get("name", "") | |
| words = name.split() | |
| if len(words) >= 2: | |
| cleaned = [] | |
| for w in words: | |
| wl = w.lower() | |
| dup = False | |
| for j, (existing, existing_lower) in enumerate(cleaned): | |
| if wl == existing_lower or wl in existing_lower or existing_lower in wl: | |
| if len(w) >= len(existing): | |
| cleaned[j] = (w, wl) | |
| dup = True | |
| break | |
| if not dup: | |
| cleaned.append((w, wl)) | |
| name = " ".join(c[0] for c in cleaned) | |
| result["name"] = name | |
| return result | |
| def execute(ingredient_string: str) -> Dict[str, Any]: | |
| """Parse an ingredient string and extract components""" | |
| ingredient = ingredient_string.strip() | |
| # Remove leading symbols (▢, •, -, +) | |
| ingredient = re.sub(r"^[\s▢•\-+]*", "", ingredient).strip() | |
| # Remove numbered list markers (e.g., "3. ") | |
| ingredient = re.sub(r"^\d+\.\s*", "", ingredient).strip() | |
| # Handle conversational phrases before regex parsing | |
| conv_result = QuantityParserTool._handle_conversational_phrases(ingredient, ingredient_string) | |
| if conv_result: | |
| return QuantityParserTool._post_process(conv_result) | |
| # Match ANY numeric quantity, unicode fraction, or fraction expression at the start | |
| num_match = re.match(r"^([\d½¼¾⅓⅔⅛\/\.\-\s]+)", ingredient) | |
| if num_match and num_match.group(1).strip(): | |
| quantity_num = num_match.group(1).strip() | |
| remainder = ingredient[num_match.end():].strip() | |
| # Check if the remainder starts with a recognized unit | |
| unit_pattern = r"^(cups?|tablespoons?|tbsp|teaspoons?|tsp|grams?|g|kg|ml|liters?|l)\b" | |
| unit_match = re.match(unit_pattern, remainder, re.IGNORECASE) | |
| if unit_match: | |
| unit = unit_match.group(1) | |
| name = remainder[unit_match.end():].strip() | |
| quantity = f"{quantity_num} {unit}" | |
| else: | |
| # No standard unit matched | |
| quantity = quantity_num | |
| name = remainder | |
| # Clean up content in parentheses from the name | |
| name = re.sub(r"\([^)]*\)", "", name).strip() | |
| return QuantityParserTool._post_process({ | |
| "quantity": quantity, | |
| "unit": unit_match.group(1) if unit_match else "", | |
| "name": QuantityParserTool._strip_prep_words(name), | |
| "raw_input": ingredient_string | |
| }) | |
| # Fallback: Check if it starts with a single digit or fraction symbol | |
| fallback_match = re.match(r"^([0-9½¼¾⅓⅔⅛])\s*(.*)$", ingredient) | |
| if fallback_match: | |
| return QuantityParserTool._post_process({ | |
| "quantity": fallback_match.group(1), | |
| "unit": "", | |
| "name": QuantityParserTool._strip_prep_words(fallback_match.group(2).strip()), | |
| "raw_input": ingredient_string | |
| }) | |
| # No quantity found, assume ingredient name only | |
| return QuantityParserTool._post_process({ | |
| "quantity": "", | |
| "unit": "", | |
| "name": QuantityParserTool._strip_prep_words(ingredient), | |
| "raw_input": ingredient_string | |
| }) | |