From aa7fc628f46ce584f44dea162bc9bcc7957d8dfe Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Mon, 26 Jan 2026 10:56:35 -0500 Subject: [PATCH] initial python code commit --- README.md | 32 +++++++- pylang_serv/__init__.py | 3 + pylang_serv/apis/__init__.py | 1 + pylang_serv/parser.py | 155 +++++++++++++++++++++++++++++++++++ pylang_serv/server.py | 44 ++++++++++ pyproject.toml | 31 +++++++ 6 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 pylang_serv/__init__.py create mode 100644 pylang_serv/apis/__init__.py create mode 100644 pylang_serv/parser.py create mode 100644 pylang_serv/server.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md index 1bae4ba..756e086 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ # 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 diff --git a/pylang_serv/__init__.py b/pylang_serv/__init__.py new file mode 100644 index 0000000..5f632d7 --- /dev/null +++ b/pylang_serv/__init__.py @@ -0,0 +1,3 @@ +"""pylang_serv - Natural language search microservice for Nutra.""" + +__version__ = "0.1.0" diff --git a/pylang_serv/apis/__init__.py b/pylang_serv/apis/__init__.py new file mode 100644 index 0000000..081eccc --- /dev/null +++ b/pylang_serv/apis/__init__.py @@ -0,0 +1 @@ +"""External API clients.""" diff --git a/pylang_serv/parser.py b/pylang_serv/parser.py new file mode 100644 index 0000000..9f901c4 --- /dev/null +++ b/pylang_serv/parser.py @@ -0,0 +1,155 @@ +"""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[\d./]+(?:\s*[\d./]+)?)\s*" # quantity (e.g., "2", "1/2", "1 1/2") + r"(?P[a-zA-Z]+)?\s*" # optional unit + r"(?:of\s+)?" # optional "of" + r"(?P.+?)\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 diff --git a/pylang_serv/server.py b/pylang_serv/server.py new file mode 100644 index 0000000..08e9db4 --- /dev/null +++ b/pylang_serv/server.py @@ -0,0 +1,44 @@ +"""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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7a6e7d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pylang_serv" +version = "0.1.0" +description = "Natural language search microservice for Nutra" +readme = "README.md" +license = {text = "GPL-3.0-or-later"} +requires-python = ">=3.9" +dependencies = [ + "flask>=3.0", + "requests>=2.28", + "pint>=0.23", +] + +[project.optional-dependencies] +nlp = [ + "spacy>=3.7", +] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", +] + +[project.scripts] +pylang-serv = "pylang_serv.server:main" + +[tool.setuptools.packages.find] +where = ["."] -- 2.52.0