From b901701eba581534b92750ff22254c7844970bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20P=C3=BCschel?= Date: Fri, 10 Jul 2026 15:46:50 +0200 Subject: [PATCH 1/2] refactor main.py and separate concerns --- gen-ai/config.py | 15 ++ gen-ai/llm.py | 106 +++++++++ gen-ai/main.py | 301 +----------------------- gen-ai/prompts.py | 120 ++++++++++ gen-ai/pytest.ini | 2 +- gen-ai/routers.py | 74 ++++++ gen-ai/schemas.py | 34 +++ gen-ai/tests/conftest.py | 2 +- gen-ai/tests/test_endpoint_merge.py | 3 +- gen-ai/tests/test_endpoint_parse.py | 3 +- gen-ai/tests/test_parse_json_content.py | 2 +- gen-ai/tests/test_prompts.py | 2 +- gen-ai/tests/test_provider.py | 7 +- 13 files changed, 364 insertions(+), 307 deletions(-) create mode 100644 gen-ai/config.py create mode 100644 gen-ai/llm.py create mode 100644 gen-ai/prompts.py create mode 100644 gen-ai/routers.py create mode 100644 gen-ai/schemas.py diff --git a/gen-ai/config.py b/gen-ai/config.py new file mode 100644 index 0000000..2b41de7 --- /dev/null +++ b/gen-ai/config.py @@ -0,0 +1,15 @@ +import os +from typing import Literal + +from dotenv import load_dotenv + +load_dotenv() + +LOGOS_BASE_URL = "https://logos.aet.cit.tum.de/v1" +LOGOS_MODEL = "openai/gpt-oss-120b" +OPENAI_MODEL = "gpt-4o-mini" +LM_STUDIO_BASE_URL = os.getenv("LM_STUDIO_BASE_URL", "http://localhost:1234/v1") +LM_STUDIO_MODEL = os.getenv("LM_STUDIO_MODEL", "local-model") +DEFAULT_LLM_PROVIDER = "logos" + +Provider = Literal["logos", "openai", "local"] diff --git a/gen-ai/llm.py b/gen-ai/llm.py new file mode 100644 index 0000000..5b33eb6 --- /dev/null +++ b/gen-ai/llm.py @@ -0,0 +1,106 @@ +import json +import os +import re + +from fastapi import HTTPException +from openai import OpenAI, OpenAIError + +from config import ( + DEFAULT_LLM_PROVIDER, + LM_STUDIO_BASE_URL, + LM_STUDIO_MODEL, + LOGOS_BASE_URL, + LOGOS_MODEL, + OPENAI_MODEL, + Provider, +) +from schemas import Ingredient + +# Re-exported so callers can `except OpenAIError` without importing openai directly. +__all__ = [ + "OpenAI", + "OpenAIError", + "NO_LLM_NOTE", + "CANNED_INGREDIENTS", + "get_client", + "openai_available", + "normalize_provider", + "create_chat_completion", + "parse_json_content", +] + +NO_LLM_NOTE = "No LLM is currently available — showing a canned example response." + +# Shown when no LLM provider is configured/reachable, so the UI always has something to display. +CANNED_INGREDIENTS = [ + Ingredient(name="Spaghetti", quantity="400", unit="g", category="Pantry"), + Ingredient(name="Garlic", quantity="2", unit="piece", category="Produce"), + Ingredient(name="Olive oil", quantity="30", unit="ml", category="Pantry"), + Ingredient(name="Parmesan cheese", quantity="50", unit="g", category="Dairy"), + Ingredient(name="Salt", quantity="N/A", unit="N/A", category="Spices"), +] + + +def get_client(provider: Provider) -> OpenAI: + if provider == "openai": + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured") + return OpenAI(api_key=api_key) + + if provider == "local": + # LM Studio's OpenAI-compatible server doesn't validate the key, but the + # client requires a non-empty string. + return OpenAI(api_key="lm-studio", base_url=LM_STUDIO_BASE_URL) + + api_key = os.getenv("LOGOS_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="LOGOS_KEY is not configured") + return OpenAI(api_key=api_key, base_url=LOGOS_BASE_URL) + + +def openai_available() -> bool: + return bool(os.getenv("OPENAI_API_KEY")) + + +def normalize_provider(provider: str | None) -> Provider: + if provider == "local": + return "local" + # OpenAI is opt-in and requires OPENAI_API_KEY; without it we always fall back to + # Logos, which is the hard dependency for this service. + if provider == "openai" and openai_available(): + return "openai" + return DEFAULT_LLM_PROVIDER + + +def create_chat_completion(provider: Provider, messages: list[dict[str, str]]): + if provider == "openai": + model = OPENAI_MODEL + elif provider == "local": + model = LM_STUDIO_MODEL + else: + model = LOGOS_MODEL + + kwargs = { + "model": model, + "messages": messages, + # Low temperature keeps categorization deterministic so the same ingredient + # is not labelled differently across requests. + "temperature": 0, + } + if provider == "openai": + kwargs["response_format"] = {"type": "json_object"} + return get_client(provider).chat.completions.create(**kwargs) + + +def parse_json_content(content: str | None) -> dict: + if not content: + raise ValueError("LLM response was empty") + + try: + return json.loads(content) + except json.JSONDecodeError as e: + match = re.search(r"\{.*\}", content, re.DOTALL) + if not match: + raise ValueError(f"LLM response was not valid JSON: {e}") from None + return json.loads(match.group(0)) diff --git a/gen-ai/main.py b/gen-ai/main.py index 287f90e..6f47636 100644 --- a/gen-ai/main.py +++ b/gen-ai/main.py @@ -1,15 +1,7 @@ -import json -import os -import re -from typing import Literal - -from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException -from openai import OpenAI, OpenAIError +from fastapi import FastAPI from prometheus_fastapi_instrumentator import Instrumentator -from pydantic import BaseModel -load_dotenv() +from routers import router app = FastAPI( title="ByteBite Gen AI Service API", @@ -20,291 +12,4 @@ # Expose default HTTP request metrics for Prometheus at GET /metrics. Instrumentator().instrument(app).expose(app) -LOGOS_BASE_URL = "https://logos.aet.cit.tum.de/v1" -LOGOS_MODEL = "openai/gpt-oss-120b" -OPENAI_MODEL = "gpt-4o-mini" -LM_STUDIO_BASE_URL = os.getenv("LM_STUDIO_BASE_URL", "http://localhost:1234/v1") -LM_STUDIO_MODEL = os.getenv("LM_STUDIO_MODEL", "local-model") -DEFAULT_LLM_PROVIDER = "logos" - -Provider = Literal["logos", "openai", "local"] - -BASE_SYSTEM_PROMPT = """ -## Role -You are a specialized Grocery List Agent. Your sole task is to convert recipe text into a structured, metric-only JSON grocery list. - -## Task -0. If the input contains narrative prose, stories, or blog content surrounding a recipe, - ignore everything that is not part of the recipe itself. Extract only the ingredients. -1. If the input is a dish NAME with no ingredient list (e.g. "Chicken Curry"), - GENERATE the full set of ingredients a typical recipe for that dish requires. - If the input contains recipe text, EXTRACT the ingredients from it instead. - Never return an empty ingredient list for a valid dish. -2. Convert ALL non-metric units to the metric system (grams or milliliters). -3. If an ingredient is a "count" (e.g., "2 onions"), use "piece" or "unit" as the unit. -4. If a quantity/unit is missing or vague ("salt to taste"), use "N/A" for those fields. - -## Conversion Rules -- 1 cup ≈ 240 ml (liquids) or the appropriate weight in grams (dry). -- 1 tablespoon (tbsp) = 15 ml -- 1 teaspoon (tsp) = 5 ml -- 1 ounce (oz) ≈ 28 g -- 1 pound (lb) ≈ 450 g -- Use decimal points instead of fractions (e.g., 0.5 instead of 1/2). - -## Categorization -Assign each ingredient EXACTLY ONE category. You MUST output the category token verbatim -from the list below — do not invent new categories, do not add "&", and do not pluralize. - -- Produce — fresh fruit, vegetables, fresh herbs, salad, mushrooms, garlic, onions, lemons. -- Dairy — milk, cream, butter, cheese, yogurt, eggs, and plant-milk alternatives. -- Meat — beef, pork, chicken, lamb, sausage, bacon, deli meat. -- Seafood — fish, shrimp, prawns, mussels, squid, and other shellfish. -- Bakery — bread, rolls, tortillas, buns, pastries, cakes. -- Pantry — shelf-stable goods: dry pasta, rice, flour, sugar, oil, vinegar, canned/jarred - goods, beans, lentils, stock, sauces, condiments, ketchup, mustard, baking needs (yeast, - baking soda, cocoa), nuts, snacks, and international shelf-stable items (soy sauce, coconut milk). -- Frozen — anything sold frozen: frozen vegetables, frozen fruit, ice cream, frozen fish. -- Beverages — water, juice, soda, coffee, tea, wine, beer, spirits. -- Spices — dried/ground spices and seasonings: salt, pepper, paprika, cinnamon, dried herbs, chili flakes. -- Other — use ONLY when an ingredient genuinely fits none of the above. - -### Categorization rules -- Pick the SINGLE best-fitting category for the ingredient's most common store location. -- Prefer the form the recipe specifies: "fresh basil" -> Produce, "dried basil" -> Spices, - "fresh tomatoes" -> Produce, "canned tomatoes" -> Pantry, "frozen peas" -> Frozen. -- "Other" is a last resort. Before using it, re-check whether the item fits Produce, Pantry, or Spices. -- Be consistent: the same ingredient must always receive the same category. - -{dietary_section} - -## Constraints -- Return ONLY valid JSON. -- Do not use markdown code blocks (```json). -- Format: {{"ingredients": [{{"name": "string", "quantity": "string", "unit": "string", "category": "string", "restricted": boolean, "alternative": "string or null"}}]}} -- Set "restricted" to false and "alternative" to null for unrestricted ingredients. - -## Input Data -[User Input Follows] - -""" - -DIETARY_RULES = { - "Vegan": "any animal product (meat, fish, dairy, eggs, honey, gelatin)", - "Vegetarian": "meat or fish (beef, pork, chicken, lamb, seafood, gelatin — but dairy and eggs are allowed)", - "Gluten Free": "gluten-containing ingredients (wheat flour, bread, pasta, barley, rye, soy sauce, malt)", - "Lactose Free": "lactose-containing dairy (milk, cream, butter, cheese, yogurt — lactose-free versions are acceptable)", -} - - -def build_system_prompt(dietary_restrictions: list[str]) -> str: - if not dietary_restrictions: - dietary_section = ( - "## Dietary Restrictions\n" - "No dietary restrictions specified. Set \"restricted\" to false and \"alternative\" to null for all ingredients." - ) - else: - rules = "\n".join( - f"- **{r}**: flag any ingredient that contains {DIETARY_RULES.get(r, r)}." - for r in dietary_restrictions - if r in DIETARY_RULES - ) - dietary_section = ( - f"## Dietary Restrictions\n" - f"The user has the following dietary restrictions: {', '.join(dietary_restrictions)}.\n\n" - f"{rules}\n\n" - f"For each flagged ingredient set \"restricted\" to true and provide a suitable " - f"\"alternative\" (e.g. \"oat milk\" for milk on a vegan diet). " - f"If no reasonable alternative exists, set \"alternative\" to null." - ) - return BASE_SYSTEM_PROMPT.format(dietary_section=dietary_section) - - -class Ingredient(BaseModel): - name: str - quantity: str - unit: str - category: str - restricted: bool = False - alternative: str | None = None - - -class GenerateRequest(BaseModel): - dish: str - dietary_restrictions: list[str] = [] - llm_provider: str = DEFAULT_LLM_PROVIDER - - -class GenerateResponse(BaseModel): - dish: str - ingredients: list[Ingredient] - note: str | None = None - - -class MergeRequest(BaseModel): - recipes: list[list[Ingredient]] - llm_provider: str = DEFAULT_LLM_PROVIDER - - -class MergeResponse(BaseModel): - ingredients: list[Ingredient] - note: str | None = None - - -NO_LLM_NOTE = "No LLM is currently available — showing a canned example response." - -# Shown when no LLM provider is configured/reachable, so the UI always has something to display. -CANNED_INGREDIENTS = [ - Ingredient(name="Spaghetti", quantity="400", unit="g", category="Pantry"), - Ingredient(name="Garlic", quantity="2", unit="piece", category="Produce"), - Ingredient(name="Olive oil", quantity="30", unit="ml", category="Pantry"), - Ingredient(name="Parmesan cheese", quantity="50", unit="g", category="Dairy"), - Ingredient(name="Salt", quantity="N/A", unit="N/A", category="Spices"), -] - - -MERGE_PROMPT = """ -## Role -You are a Grocery List Merging Agent. Your sole task is to combine multiple ingredient lists into one unified, deduplicated shopping list. - -## Task -1. Merge all provided ingredient lists into a single list. -2. Combine duplicate ingredients: if the same ingredient appears in multiple lists, sum their quantities. -3. Apply semantic deduplication: treat ingredients that refer to the same thing as duplicates, including synonyms and regional name variants. - - Examples of synonyms to merge: "spring onion" / "scallion", "bell pepper" / "capsicum", - "coriander" / "cilantro", "aubergine" / "eggplant", "courgette" / "zucchini", - "plain flour" / "all-purpose flour", "bicarbonate of soda" / "baking soda". - - Use the more common English name as the canonical name in the output. - - When merging synonyms, sum their quantities exactly as you would for exact-name duplicates. - - Do NOT merge ingredients that are merely similar but distinct - (e.g. "cherry tomatoes" and "tomatoes", "garlic clove" and "garlic powder"). -4. If units differ for the same ingredient, convert to a common metric unit before summing. -5. If a quantity is "N/A" and the other is numeric, keep the numeric value. -6. If both quantities are "N/A", keep "N/A". -7. Preserve the category from the first occurrence of each ingredient. - -## Constraints -- Return ONLY valid JSON. -- Do not use markdown code blocks (```json). -- Format: {"ingredients": [{"name": "string", "quantity": "string", "unit": "string", "category": "string"}]} - -## Input Data -[Ingredient lists follow as JSON] - -""" - - -@app.get("/health") -def health(): - return {"status": "ok", "openai_available": openai_available()} - - -def get_client(provider: Provider) -> OpenAI: - if provider == "openai": - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured") - return OpenAI(api_key=api_key) - - if provider == "local": - # LM Studio's OpenAI-compatible server doesn't validate the key, but the - # client requires a non-empty string. - return OpenAI(api_key="lm-studio", base_url=LM_STUDIO_BASE_URL) - - api_key = os.getenv("LOGOS_KEY") - if not api_key: - raise HTTPException(status_code=500, detail="LOGOS_KEY is not configured") - return OpenAI(api_key=api_key, base_url=LOGOS_BASE_URL) - - -def openai_available() -> bool: - return bool(os.getenv("OPENAI_API_KEY")) - - -def normalize_provider(provider: str | None) -> Provider: - if provider == "local": - return "local" - # OpenAI is opt-in and requires OPENAI_API_KEY; without it we always fall back to - # Logos, which is the hard dependency for this service. - if provider == "openai" and openai_available(): - return "openai" - return DEFAULT_LLM_PROVIDER - - -def create_chat_completion(provider: Provider, messages: list[dict[str, str]]): - if provider == "openai": - model = OPENAI_MODEL - elif provider == "local": - model = LM_STUDIO_MODEL - else: - model = LOGOS_MODEL - - kwargs = { - "model": model, - "messages": messages, - # Low temperature keeps categorization deterministic so the same ingredient - # is not labelled differently across requests. - "temperature": 0, - } - if provider == "openai": - kwargs["response_format"] = {"type": "json_object"} - return get_client(provider).chat.completions.create(**kwargs) - - -def parse_json_content(content: str | None) -> dict: - if not content: - raise ValueError("LLM response was empty") - - try: - return json.loads(content) - except json.JSONDecodeError as e: - match = re.search(r"\{.*\}", content, re.DOTALL) - if not match: - raise ValueError(f"LLM response was not valid JSON: {e}") from None - return json.loads(match.group(0)) - - -@app.post("/api/ai/parse", response_model=GenerateResponse) -def generate(request: GenerateRequest): - provider = normalize_provider(request.llm_provider) - system_prompt = build_system_prompt(request.dietary_restrictions) - try: - response = create_chat_completion( - provider, - [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": request.dish}, - ], - ) - data = parse_json_content(response.choices[0].message.content) - ingredients = [Ingredient(**item) for item in data["ingredients"]] - except (HTTPException, OpenAIError, KeyError, ValueError): - return GenerateResponse(dish=request.dish, ingredients=CANNED_INGREDIENTS, note=NO_LLM_NOTE) - - return GenerateResponse(dish=request.dish, ingredients=ingredients) - - -@app.post("/api/ai/merge", response_model=MergeResponse) -def merge(request: MergeRequest): - provider = normalize_provider(request.llm_provider) - recipes_json = json.dumps( - [[ing.model_dump() for ing in recipe] for recipe in request.recipes], - indent=2, - ) - - try: - response = create_chat_completion( - provider, - [ - {"role": "system", "content": MERGE_PROMPT}, - {"role": "user", "content": recipes_json}, - ], - ) - data = parse_json_content(response.choices[0].message.content) - ingredients = [Ingredient(**item) for item in data["ingredients"]] - except (HTTPException, OpenAIError, KeyError, ValueError): - # Fall back to the first recipe as-is so the merge UI still has something to show. - fallback = [ing for recipe in request.recipes for ing in recipe][:5] or CANNED_INGREDIENTS - return MergeResponse(ingredients=fallback, note=NO_LLM_NOTE) - - return MergeResponse(ingredients=ingredients) +app.include_router(router) diff --git a/gen-ai/prompts.py b/gen-ai/prompts.py new file mode 100644 index 0000000..e0cdcb6 --- /dev/null +++ b/gen-ai/prompts.py @@ -0,0 +1,120 @@ +BASE_SYSTEM_PROMPT = """ +## Role +You are a specialized Grocery List Agent. Your sole task is to convert recipe text into a structured, metric-only JSON grocery list. + +## Task +0. If the input contains narrative prose, stories, or blog content surrounding a recipe, + ignore everything that is not part of the recipe itself. Extract only the ingredients. +1. If the input is a dish NAME with no ingredient list (e.g. "Chicken Curry"), + GENERATE the full set of ingredients a typical recipe for that dish requires. + If the input contains recipe text, EXTRACT the ingredients from it instead. + Never return an empty ingredient list for a valid dish. +2. Convert ALL non-metric units to the metric system (grams or milliliters). +3. If an ingredient is a "count" (e.g., "2 onions"), use "piece" or "unit" as the unit. +4. If a quantity/unit is missing or vague ("salt to taste"), use "N/A" for those fields. + +## Conversion Rules +- 1 cup ≈ 240 ml (liquids) or the appropriate weight in grams (dry). +- 1 tablespoon (tbsp) = 15 ml +- 1 teaspoon (tsp) = 5 ml +- 1 ounce (oz) ≈ 28 g +- 1 pound (lb) ≈ 450 g +- Use decimal points instead of fractions (e.g., 0.5 instead of 1/2). + +## Categorization +Assign each ingredient EXACTLY ONE category. You MUST output the category token verbatim +from the list below — do not invent new categories, do not add "&", and do not pluralize. + +- Produce — fresh fruit, vegetables, fresh herbs, salad, mushrooms, garlic, onions, lemons. +- Dairy — milk, cream, butter, cheese, yogurt, eggs, and plant-milk alternatives. +- Meat — beef, pork, chicken, lamb, sausage, bacon, deli meat. +- Seafood — fish, shrimp, prawns, mussels, squid, and other shellfish. +- Bakery — bread, rolls, tortillas, buns, pastries, cakes. +- Pantry — shelf-stable goods: dry pasta, rice, flour, sugar, oil, vinegar, canned/jarred + goods, beans, lentils, stock, sauces, condiments, ketchup, mustard, baking needs (yeast, + baking soda, cocoa), nuts, snacks, and international shelf-stable items (soy sauce, coconut milk). +- Frozen — anything sold frozen: frozen vegetables, frozen fruit, ice cream, frozen fish. +- Beverages — water, juice, soda, coffee, tea, wine, beer, spirits. +- Spices — dried/ground spices and seasonings: salt, pepper, paprika, cinnamon, dried herbs, chili flakes. +- Other — use ONLY when an ingredient genuinely fits none of the above. + +### Categorization rules +- Pick the SINGLE best-fitting category for the ingredient's most common store location. +- Prefer the form the recipe specifies: "fresh basil" -> Produce, "dried basil" -> Spices, + "fresh tomatoes" -> Produce, "canned tomatoes" -> Pantry, "frozen peas" -> Frozen. +- "Other" is a last resort. Before using it, re-check whether the item fits Produce, Pantry, or Spices. +- Be consistent: the same ingredient must always receive the same category. + +{dietary_section} + +## Constraints +- Return ONLY valid JSON. +- Do not use markdown code blocks (```json). +- Format: {{"ingredients": [{{"name": "string", "quantity": "string", "unit": "string", "category": "string", "restricted": boolean, "alternative": "string or null"}}]}} +- Set "restricted" to false and "alternative" to null for unrestricted ingredients. + +## Input Data +[User Input Follows] + +""" + +DIETARY_RULES = { + "Vegan": "any animal product (meat, fish, dairy, eggs, honey, gelatin)", + "Vegetarian": "meat or fish (beef, pork, chicken, lamb, seafood, gelatin — but dairy and eggs are allowed)", + "Gluten Free": "gluten-containing ingredients (wheat flour, bread, pasta, barley, rye, soy sauce, malt)", + "Lactose Free": "lactose-containing dairy (milk, cream, butter, cheese, yogurt — lactose-free versions are acceptable)", +} + + +def build_system_prompt(dietary_restrictions: list[str]) -> str: + if not dietary_restrictions: + dietary_section = ( + "## Dietary Restrictions\n" + "No dietary restrictions specified. Set \"restricted\" to false and \"alternative\" to null for all ingredients." + ) + else: + rules = "\n".join( + f"- **{r}**: flag any ingredient that contains {DIETARY_RULES.get(r, r)}." + for r in dietary_restrictions + if r in DIETARY_RULES + ) + dietary_section = ( + f"## Dietary Restrictions\n" + f"The user has the following dietary restrictions: {', '.join(dietary_restrictions)}.\n\n" + f"{rules}\n\n" + f"For each flagged ingredient set \"restricted\" to true and provide a suitable " + f"\"alternative\" (e.g. \"oat milk\" for milk on a vegan diet). " + f"If no reasonable alternative exists, set \"alternative\" to null." + ) + return BASE_SYSTEM_PROMPT.format(dietary_section=dietary_section) + + +MERGE_PROMPT = """ +## Role +You are a Grocery List Merging Agent. Your sole task is to combine multiple ingredient lists into one unified, deduplicated shopping list. + +## Task +1. Merge all provided ingredient lists into a single list. +2. Combine duplicate ingredients: if the same ingredient appears in multiple lists, sum their quantities. +3. Apply semantic deduplication: treat ingredients that refer to the same thing as duplicates, including synonyms and regional name variants. + - Examples of synonyms to merge: "spring onion" / "scallion", "bell pepper" / "capsicum", + "coriander" / "cilantro", "aubergine" / "eggplant", "courgette" / "zucchini", + "plain flour" / "all-purpose flour", "bicarbonate of soda" / "baking soda". + - Use the more common English name as the canonical name in the output. + - When merging synonyms, sum their quantities exactly as you would for exact-name duplicates. + - Do NOT merge ingredients that are merely similar but distinct + (e.g. "cherry tomatoes" and "tomatoes", "garlic clove" and "garlic powder"). +4. If units differ for the same ingredient, convert to a common metric unit before summing. +5. If a quantity is "N/A" and the other is numeric, keep the numeric value. +6. If both quantities are "N/A", keep "N/A". +7. Preserve the category from the first occurrence of each ingredient. + +## Constraints +- Return ONLY valid JSON. +- Do not use markdown code blocks (```json). +- Format: {"ingredients": [{"name": "string", "quantity": "string", "unit": "string", "category": "string"}]} + +## Input Data +[Ingredient lists follow as JSON] + +""" diff --git a/gen-ai/pytest.ini b/gen-ai/pytest.ini index ed889ec..8d63b59 100644 --- a/gen-ai/pytest.ini +++ b/gen-ai/pytest.ini @@ -1,3 +1,3 @@ [pytest] testpaths = tests -addopts = -ra --cov=main --cov-report=term-missing --cov-fail-under=85 +addopts = -ra --cov=config --cov=schemas --cov=prompts --cov=llm --cov=routers --cov=main --cov-report=term-missing --cov-fail-under=85 diff --git a/gen-ai/routers.py b/gen-ai/routers.py new file mode 100644 index 0000000..29dd905 --- /dev/null +++ b/gen-ai/routers.py @@ -0,0 +1,74 @@ +import json + +from fastapi import APIRouter, HTTPException + +from llm import ( + CANNED_INGREDIENTS, + NO_LLM_NOTE, + OpenAIError, + create_chat_completion, + normalize_provider, + openai_available, + parse_json_content, +) +from prompts import MERGE_PROMPT, build_system_prompt +from schemas import ( + GenerateRequest, + GenerateResponse, + Ingredient, + MergeRequest, + MergeResponse, +) + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"status": "ok", "openai_available": openai_available()} + + +@router.post("/api/ai/parse", response_model=GenerateResponse) +def generate(request: GenerateRequest): + provider = normalize_provider(request.llm_provider) + system_prompt = build_system_prompt(request.dietary_restrictions) + try: + response = create_chat_completion( + provider, + [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": request.dish}, + ], + ) + data = parse_json_content(response.choices[0].message.content) + ingredients = [Ingredient(**item) for item in data["ingredients"]] + except (HTTPException, OpenAIError, KeyError, ValueError): + return GenerateResponse(dish=request.dish, ingredients=CANNED_INGREDIENTS, note=NO_LLM_NOTE) + + return GenerateResponse(dish=request.dish, ingredients=ingredients) + + +@router.post("/api/ai/merge", response_model=MergeResponse) +def merge(request: MergeRequest): + provider = normalize_provider(request.llm_provider) + recipes_json = json.dumps( + [[ing.model_dump() for ing in recipe] for recipe in request.recipes], + indent=2, + ) + + try: + response = create_chat_completion( + provider, + [ + {"role": "system", "content": MERGE_PROMPT}, + {"role": "user", "content": recipes_json}, + ], + ) + data = parse_json_content(response.choices[0].message.content) + ingredients = [Ingredient(**item) for item in data["ingredients"]] + except (HTTPException, OpenAIError, KeyError, ValueError): + # Fall back to the first recipe as-is so the merge UI still has something to show. + fallback = [ing for recipe in request.recipes for ing in recipe][:5] or CANNED_INGREDIENTS + return MergeResponse(ingredients=fallback, note=NO_LLM_NOTE) + + return MergeResponse(ingredients=ingredients) diff --git a/gen-ai/schemas.py b/gen-ai/schemas.py new file mode 100644 index 0000000..994b242 --- /dev/null +++ b/gen-ai/schemas.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel + +from config import DEFAULT_LLM_PROVIDER + + +class Ingredient(BaseModel): + name: str + quantity: str + unit: str + category: str + restricted: bool = False + alternative: str | None = None + + +class GenerateRequest(BaseModel): + dish: str + dietary_restrictions: list[str] = [] + llm_provider: str = DEFAULT_LLM_PROVIDER + + +class GenerateResponse(BaseModel): + dish: str + ingredients: list[Ingredient] + note: str | None = None + + +class MergeRequest(BaseModel): + recipes: list[list[Ingredient]] + llm_provider: str = DEFAULT_LLM_PROVIDER + + +class MergeResponse(BaseModel): + ingredients: list[Ingredient] + note: str | None = None diff --git a/gen-ai/tests/conftest.py b/gen-ai/tests/conftest.py index 69fa82d..1a0c313 100644 --- a/gen-ai/tests/conftest.py +++ b/gen-ai/tests/conftest.py @@ -27,7 +27,7 @@ def _fake_response(content: str): @pytest.fixture def mock_openai_client(): - with patch("main.OpenAI") as mock_openai_cls: + with patch("llm.OpenAI") as mock_openai_cls: mock_instance = mock_openai_cls.return_value mock_instance.chat.completions.create = MagicMock( return_value=_fake_response(json.dumps({"ingredients": []})) diff --git a/gen-ai/tests/test_endpoint_merge.py b/gen-ai/tests/test_endpoint_merge.py index 8f85b2c..e055f06 100644 --- a/gen-ai/tests/test_endpoint_merge.py +++ b/gen-ai/tests/test_endpoint_merge.py @@ -1,6 +1,7 @@ import json -from main import LOGOS_BASE_URL, LOGOS_MODEL, NO_LLM_NOTE +from config import LOGOS_BASE_URL, LOGOS_MODEL +from llm import NO_LLM_NOTE from tests.conftest import _fake_response diff --git a/gen-ai/tests/test_endpoint_parse.py b/gen-ai/tests/test_endpoint_parse.py index 79c409e..764dd89 100644 --- a/gen-ai/tests/test_endpoint_parse.py +++ b/gen-ai/tests/test_endpoint_parse.py @@ -1,6 +1,7 @@ import json -from main import CANNED_INGREDIENTS, LOGOS_BASE_URL, LOGOS_MODEL, NO_LLM_NOTE, OPENAI_MODEL +from config import LOGOS_BASE_URL, LOGOS_MODEL, OPENAI_MODEL +from llm import CANNED_INGREDIENTS, NO_LLM_NOTE from tests.conftest import _fake_response diff --git a/gen-ai/tests/test_parse_json_content.py b/gen-ai/tests/test_parse_json_content.py index 736331d..5996e40 100644 --- a/gen-ai/tests/test_parse_json_content.py +++ b/gen-ai/tests/test_parse_json_content.py @@ -1,6 +1,6 @@ import pytest -from main import parse_json_content +from llm import parse_json_content def test_valid_json_parsed(): diff --git a/gen-ai/tests/test_prompts.py b/gen-ai/tests/test_prompts.py index bc6445c..bc5e60b 100644 --- a/gen-ai/tests/test_prompts.py +++ b/gen-ai/tests/test_prompts.py @@ -1,4 +1,4 @@ -from main import DIETARY_RULES, build_system_prompt +from prompts import DIETARY_RULES, build_system_prompt CATEGORY_TOKENS = [ "Produce", diff --git a/gen-ai/tests/test_provider.py b/gen-ai/tests/test_provider.py index dee1008..aa58897 100644 --- a/gen-ai/tests/test_provider.py +++ b/gen-ai/tests/test_provider.py @@ -3,7 +3,8 @@ import pytest from fastapi import HTTPException -from main import LOGOS_BASE_URL, get_client, normalize_provider +from config import LOGOS_BASE_URL +from llm import get_client, normalize_provider @pytest.mark.parametrize( @@ -36,7 +37,7 @@ def test_logos_missing_key_raises_500(monkeypatch): def test_openai_constructs_client_with_key(): - with patch("main.OpenAI") as mock_openai_cls: + with patch("llm.OpenAI") as mock_openai_cls: get_client("openai") _, kwargs = mock_openai_cls.call_args assert kwargs["api_key"] == "test-openai-key" @@ -44,7 +45,7 @@ def test_openai_constructs_client_with_key(): def test_logos_constructs_client_with_base_url(): - with patch("main.OpenAI") as mock_openai_cls: + with patch("llm.OpenAI") as mock_openai_cls: get_client("logos") _, kwargs = mock_openai_cls.call_args assert kwargs["api_key"] == "test-logos-key" From aaea9bcd43aa5bdad3ba4e42bb2780e533460231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20P=C3=BCschel?= Date: Fri, 10 Jul 2026 15:56:56 +0200 Subject: [PATCH 2/2] linting fix --- gen-ai/tests/test_endpoint_merge.py | 1 - gen-ai/tests/test_endpoint_parse.py | 1 - 2 files changed, 2 deletions(-) diff --git a/gen-ai/tests/test_endpoint_merge.py b/gen-ai/tests/test_endpoint_merge.py index e055f06..33a08f3 100644 --- a/gen-ai/tests/test_endpoint_merge.py +++ b/gen-ai/tests/test_endpoint_merge.py @@ -2,7 +2,6 @@ from config import LOGOS_BASE_URL, LOGOS_MODEL from llm import NO_LLM_NOTE - from tests.conftest import _fake_response diff --git a/gen-ai/tests/test_endpoint_parse.py b/gen-ai/tests/test_endpoint_parse.py index 764dd89..9ab6752 100644 --- a/gen-ai/tests/test_endpoint_parse.py +++ b/gen-ai/tests/test_endpoint_parse.py @@ -2,7 +2,6 @@ from config import LOGOS_BASE_URL, LOGOS_MODEL, OPENAI_MODEL from llm import CANNED_INGREDIENTS, NO_LLM_NOTE - from tests.conftest import _fake_response