# search-server
-Python mini-server to make intuitive, natural-language searches a reality.
\ No newline at end of file
+Python mini-server to make intuitive, natural-language searches a reality.
+
+## Quick Start
+
+```bash
+# Install
+pip install -e .
+
+# Run server
+pylang-serv
+# or: python -m pylang_serv.server
+
+# Test
+curl http://localhost:5001/health
+curl -X POST http://localhost:5001/parse -H "Content-Type: application/json" -d '{"text": "2 cups flour"}'
+```
+
+## Endpoints
+
+| Method | Path | Description |
+|--------|----------|--------------------------------------|
+| GET | /health | Health check |
+| POST | /parse | Parse ingredient text |
+| POST | /search | Search food databases (TODO) |
+
+## Dependencies
+
+- **flask**: HTTP server
+- **pint**: Unit conversions (cups → grams)
+- **requests**: External API calls
+- **spacy** (optional): Advanced NLP parsing
\ No newline at end of file
--- /dev/null
+"""Natural language ingredient parser.
+
+Parses strings like "2 cups flour" into structured data:
+{"quantity": 2.0, "unit": "cup", "food": "flour", "grams": 250.0}
+"""
+
+import re
+from typing import Optional
+
+import pint
+
+# Unit registry for conversions
+ureg = pint.UnitRegistry()
+
+# Common cooking unit aliases
+UNIT_ALIASES = {
+ "tbsp": "tablespoon",
+ "tbs": "tablespoon",
+ "tb": "tablespoon",
+ "tsp": "teaspoon",
+ "ts": "teaspoon",
+ "c": "cup",
+ "oz": "ounce",
+ "lb": "pound",
+ "lbs": "pound",
+ "g": "gram",
+ "kg": "kilogram",
+ "ml": "milliliter",
+ "l": "liter",
+}
+
+# Approximate density conversions (grams per cup) for common ingredients
+# Used when converting volume to weight
+DENSITY_MAP = {
+ "flour": 125,
+ "all-purpose flour": 125,
+ "bread flour": 127,
+ "sugar": 200,
+ "brown sugar": 220,
+ "butter": 227,
+ "milk": 245,
+ "water": 237,
+ "rice": 185,
+ "oats": 80,
+ "honey": 340,
+ "oil": 218,
+ "salt": 288,
+ # Default for unknown foods
+ "_default": 150,
+}
+
+# Pattern to match: [quantity] [unit] [of] [food]
+INGREDIENT_PATTERN = re.compile(
+ r"^\s*"
+ r"(?P<quantity>[\d./]+(?:\s*[\d./]+)?)\s*" # quantity (e.g., "2", "1/2", "1 1/2")
+ r"(?P<unit>[a-zA-Z]+)?\s*" # optional unit
+ r"(?:of\s+)?" # optional "of"
+ r"(?P<food>.+?)\s*$", # food name
+ re.IGNORECASE,
+)
+
+
+def parse_fraction(s: str) -> float:
+ """Parse a string that might be a fraction like '1/2' or '1 1/2'."""
+ s = s.strip()
+ parts = s.split()
+
+ if len(parts) == 2:
+ # Mixed number like "1 1/2"
+ whole = float(parts[0])
+ frac = parse_fraction(parts[1])
+ return whole + frac
+
+ if "/" in s:
+ num, denom = s.split("/")
+ return float(num) / float(denom)
+
+ return float(s)
+
+
+def normalize_unit(unit: Optional[str]) -> Optional[str]:
+ """Normalize unit to standard form."""
+ if not unit:
+ return None
+ unit = unit.lower().rstrip("s") # Remove plural 's'
+ return UNIT_ALIASES.get(unit, unit)
+
+
+def get_grams(quantity: float, unit: Optional[str], food: str) -> Optional[float]:
+ """Convert quantity + unit to grams if possible."""
+ if not unit:
+ return None
+
+ unit = normalize_unit(unit)
+ if not unit:
+ return None
+
+ # If already in grams
+ if unit == "gram":
+ return quantity
+
+ try:
+ # Try direct weight conversion
+ q = quantity * ureg(unit)
+ return q.to("gram").magnitude
+ except (pint.UndefinedUnitError, pint.DimensionalityError):
+ pass
+
+ # Volume to weight conversion using density
+ food_lower = food.lower()
+ density = DENSITY_MAP.get(food_lower, DENSITY_MAP["_default"])
+
+ try:
+ # Convert to cups first, then multiply by density
+ q = quantity * ureg(unit)
+ cups = q.to("cup").magnitude
+ return cups * density
+ except (pint.UndefinedUnitError, pint.DimensionalityError):
+ return None
+
+
+def parse_ingredient(text: str) -> dict:
+ """Parse an ingredient string into structured data.
+
+ Args:
+ text: Natural language ingredient string, e.g., "2 cups flour"
+
+ Returns:
+ Dict with keys: quantity, unit, food, grams (optional)
+ """
+ match = INGREDIENT_PATTERN.match(text)
+ if not match:
+ return {"error": "Could not parse ingredient", "text": text}
+
+ quantity_str = match.group("quantity")
+ unit = match.group("unit")
+ food = match.group("food")
+
+ try:
+ quantity = parse_fraction(quantity_str)
+ except (ValueError, ZeroDivisionError):
+ return {"error": f"Invalid quantity: {quantity_str}", "text": text}
+
+ unit = normalize_unit(unit)
+ grams = get_grams(quantity, unit, food)
+
+ result = {
+ "quantity": quantity,
+ "unit": unit,
+ "food": food.strip(),
+ }
+ if grams is not None:
+ result["grams"] = round(grams, 1)
+
+ return result
--- /dev/null
+"""Flask server for natural language ingredient parsing."""
+
+import json
+from flask import Flask, request, jsonify
+
+from .parser import parse_ingredient
+
+app = Flask(__name__)
+
+
+@app.route("/health", methods=["GET"])
+def health():
+ """Health check endpoint."""
+ return jsonify({"status": "ok"})
+
+
+@app.route("/parse", methods=["POST"])
+def parse():
+ """Parse an ingredient string.
+
+ Request body: {"text": "2 cups flour"}
+ Response: {"quantity": 2.0, "unit": "cup", "food": "flour", "grams": 250.0}
+ """
+ data = request.get_json()
+ if not data or "text" not in data:
+ return jsonify({"error": "Missing 'text' field"}), 400
+
+ result = parse_ingredient(data["text"])
+ return jsonify(result)
+
+
+@app.route("/search", methods=["POST"])
+def search():
+ """Search food databases (TODO)."""
+ return jsonify({"error": "Not implemented"}), 501
+
+
+def main():
+ """Run the Flask server."""
+ app.run(host="127.0.0.1", port=5001, debug=False)
+
+
+if __name__ == "__main__":
+ main()