Spaces:
Sleeping
Sleeping
File size: 9,017 Bytes
bab4aa8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | 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"""
@staticmethod
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"]
}
}
}
@staticmethod
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("-, ")
@staticmethod
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
@staticmethod
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
@staticmethod
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
@staticmethod
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
})
|