]> Nutra Git (v2) - nutratech/search-server.git/commitdiff
initial python code commit
authorShane Jaroch <chown_tee@proton.me>
Mon, 26 Jan 2026 15:56:35 +0000 (10:56 -0500)
committerShane Jaroch <chown_tee@proton.me>
Mon, 26 Jan 2026 15:56:35 +0000 (10:56 -0500)
README.md
pylang_serv/__init__.py [new file with mode: 0644]
pylang_serv/apis/__init__.py [new file with mode: 0644]
pylang_serv/parser.py [new file with mode: 0644]
pylang_serv/server.py [new file with mode: 0644]
pyproject.toml [new file with mode: 0644]

index 1bae4bac9fe2ed819deba6b34d72199a0ca6958c..756e0865ffebfbbf155ca97b13c2f4f237ae7b87 100644 (file)
--- 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 (file)
index 0000000..5f632d7
--- /dev/null
@@ -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 (file)
index 0000000..081eccc
--- /dev/null
@@ -0,0 +1 @@
+"""External API clients."""
diff --git a/pylang_serv/parser.py b/pylang_serv/parser.py
new file mode 100644 (file)
index 0000000..9f901c4
--- /dev/null
@@ -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<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
diff --git a/pylang_serv/server.py b/pylang_serv/server.py
new file mode 100644 (file)
index 0000000..08e9db4
--- /dev/null
@@ -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 (file)
index 0000000..a7a6e7d
--- /dev/null
@@ -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 = ["."]