This commit is contained in:
Andre
2026-03-29 20:52:20 +02:00
commit e832c4e7ce
29 changed files with 3730 additions and 0 deletions
View File
+83
View File
@@ -0,0 +1,83 @@
"""SQLite-based recipe history."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from app.config import get_settings
def _db_path() -> Path:
settings = get_settings()
return Path(settings.tmp_dir).parent / "history.db"
def init_db() -> None:
db = _db_path()
db.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(db) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS recipe_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
extracted_at TEXT NOT NULL,
title TEXT,
source_url TEXT,
image_url TEXT,
recipe_json TEXT NOT NULL,
imported_at TEXT,
tandoor_recipe_id INTEGER
)
""")
conn.commit()
def save_recipe(extract_data: dict) -> int:
recipe = extract_data.get("recipe", {})
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(_db_path()) as conn:
cur = conn.execute(
"""INSERT INTO recipe_history
(extracted_at, title, source_url, image_url, recipe_json)
VALUES (?, ?, ?, ?, ?)""",
(
now,
recipe.get("title"),
recipe.get("source_url"),
recipe.get("image_url"),
json.dumps(extract_data),
),
)
conn.commit()
return cur.lastrowid
def get_all() -> list[dict]:
with sqlite3.connect(_db_path()) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM recipe_history ORDER BY extracted_at DESC"
).fetchall()
result = []
for r in rows:
entry = dict(r)
entry["recipe_data"] = json.loads(entry.pop("recipe_json"))
result.append(entry)
return result
def delete_recipe(history_id: int) -> None:
with sqlite3.connect(_db_path()) as conn:
conn.execute("DELETE FROM recipe_history WHERE id = ?", (history_id,))
conn.commit()
def mark_imported(history_id: int, tandoor_recipe_id: Optional[int]) -> None:
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(_db_path()) as conn:
conn.execute(
"UPDATE recipe_history SET imported_at = ?, tandoor_recipe_id = ? WHERE id = ?",
(now, tandoor_recipe_id, history_id),
)
conn.commit()
+186
View File
@@ -0,0 +1,186 @@
"""3-Tier nutrition strategy: extracted → USDA FoodData → GPT-4 estimate."""
from __future__ import annotations
import logging
import re
from typing import Optional
import httpx
from app.config import get_settings
from app.models import Ingredient, NutritionInfo, Recipe
log = logging.getLogger(__name__)
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1"
MIN_USDA_COVERAGE = 0.5 # require ≥50% ingredients resolved
async def enrich_nutrition(recipe: Recipe) -> Recipe:
"""Enrich recipe with nutrition info using 3-tier fallback."""
# Tier 1: Already extracted from source
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
return recipe
if not recipe.ingredients:
return recipe
servings = recipe.servings or 1
# Tier 2: USDA FoodData
try:
nutrition = await _usda_lookup(recipe.ingredients, servings)
if nutrition and nutrition.kcal_per_serving:
recipe = recipe.model_copy(update={"nutrition": nutrition})
return recipe
except Exception as e:
log.warning("USDA lookup failed: %s", e)
# Tier 3: GPT-4 estimate (only if OpenAI key configured)
settings = get_settings()
if settings.openai_api_key:
try:
from app.services.openai_service import estimate_nutrition_gpt
nutrition = await estimate_nutrition_gpt(recipe.ingredients, servings)
recipe = recipe.model_copy(update={"nutrition": nutrition})
except Exception as e:
log.warning("GPT-4 nutrition estimate failed: %s", e)
return recipe
async def _usda_lookup(ingredients: list[Ingredient], servings: int) -> Optional[NutritionInfo]:
"""Look up each ingredient in USDA FoodData API and sum kcal."""
settings = get_settings()
api_key = settings.usda_api_key or "DEMO_KEY"
total_kcal = 0.0
resolved = 0
async with httpx.AsyncClient(timeout=10) as client:
for ing in ingredients:
try:
kcal = await _usda_ingredient_kcal(client, ing, api_key)
if kcal is not None:
total_kcal += kcal
resolved += 1
except Exception as e:
log.debug("USDA lookup for '%s' failed: %s", ing.name, e)
coverage = resolved / len(ingredients) if ingredients else 0
if coverage < MIN_USDA_COVERAGE:
log.info("USDA coverage %.0f%% < 50%%, skipping", coverage * 100)
return None
kcal_per_serving = total_kcal / servings if servings else total_kcal
return NutritionInfo(
kcal_per_serving=round(kcal_per_serving, 1),
kcal_total=round(total_kcal, 1),
servings=servings,
source="usda",
)
async def _usda_ingredient_kcal(
client: httpx.AsyncClient,
ing: Ingredient,
api_key: str,
) -> Optional[float]:
"""Search USDA for one ingredient and return estimated kcal."""
resp = await client.get(
f"{USDA_API_URL}/foods/search",
params={
"query": ing.name,
"dataType": "Foundation,SR Legacy",
"pageSize": 1,
"api_key": api_key,
},
)
resp.raise_for_status()
data = resp.json()
foods = data.get("foods", [])
if not foods:
return None
food = foods[0]
# Find Energy (kcal) nutrient
nutrients = food.get("foodNutrients", [])
kcal_per_100g = None
for n in nutrients:
name = (n.get("nutrientName") or "").lower()
unit = (n.get("unitName") or "").upper()
if "energy" in name and unit == "KCAL":
kcal_per_100g = n.get("value")
break
if kcal_per_100g is None:
return None
grams = _estimate_grams(ing)
if grams is None:
return None
return kcal_per_100g * grams / 100
def _estimate_grams(ing: Ingredient) -> Optional[float]:
"""Convert ingredient amount + unit to grams (approximate)."""
if not ing.amount:
return None
amount_val = _parse_float(ing.amount)
if amount_val is None:
return None
unit = (ing.unit or "").lower().strip()
# Direct weight
if unit in ("g", "gr", "gramm", "gram"):
return amount_val
if unit in ("kg", "kilogramm"):
return amount_val * 1000
if unit in ("mg", "milligramm"):
return amount_val / 1000
# Volume → grams (approximate, assuming water density)
if unit in ("ml", "milliliter", "millilitre"):
return amount_val
if unit in ("l", "liter", "litre"):
return amount_val * 1000
if unit in ("el", "esslöffel", "tbsp", "tablespoon"):
return amount_val * 15
if unit in ("tl", "teelöffel", "tsp", "teaspoon"):
return amount_val * 5
if unit in ("cup", "cups", "tasse"):
return amount_val * 240
# Count → fallback average
if unit in ("stück", "stk", "piece", "pieces", "", "pcs"):
return amount_val * 100 # rough average
# Unknown unit → assume grams
return amount_val
def _parse_float(s: str) -> Optional[float]:
if not s:
return None
# Handle fractions like "1/2"
frac = re.match(r"^\s*(\d+)\s*/\s*(\d+)\s*$", s)
if frac:
return int(frac.group(1)) / int(frac.group(2))
# Handle mixed like "1 1/2"
mixed = re.match(r"^\s*(\d+)\s+(\d+)\s*/\s*(\d+)\s*$", s)
if mixed:
return int(mixed.group(1)) + int(mixed.group(2)) / int(mixed.group(3))
# Range like "2-3" → take first
range_m = re.match(r"^\s*([\d.]+)\s*[-–]\s*([\d.]+)\s*$", s)
if range_m:
return float(range_m.group(1))
# Plain float
plain = re.search(r"[\d.]+", s)
if plain:
try:
return float(plain.group())
except ValueError:
return None
return None
+183
View File
@@ -0,0 +1,183 @@
"""OpenAI integration: Whisper transcription + GPT-4o-mini recipe extraction."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from openai import AsyncOpenAI, AuthenticationError, RateLimitError, APIConnectionError
from app.config import get_settings
from app.models import Ingredient, NutritionInfo, Recipe
log = logging.getLogger(__name__)
_RECIPE_SYSTEM_PROMPT = """Du bist ein Rezept-Extraktor. Extrahiere das Rezept aus dem Text und antworte NUR mit einem JSON-Objekt (kein Markdown).
JSON-Schema:
{
"title": "string",
"description": "string oder null",
"servings": integer oder null,
"prep_time_minutes": integer oder null,
"cook_time_minutes": integer oder null,
"ingredients": [
{"name": "string", "amount": "string oder null", "unit": "string oder null", "note": "string oder null"}
],
"steps": ["string"],
"kcal_per_serving": number oder null,
"tags": ["string"]
}
Regeln:
- amount ist immer ein String (z.B. "200", "1/2", "2-3")
- unit ist die Maßeinheit (g, ml, EL, TL, Stück, etc.)
- Wenn keine Kalorien erkennbar sind, setze kcal_per_serving auf null
- steps sind vollständige Zubereitungsschritte
- tags: maximal 5 relevante Schlagwörter
"""
_CAPTION_CHECK_PROMPT = """Enthält der folgende Text ein vollständiges Rezept mit Zutaten UND Zubereitungsschritten?
Antworte NUR mit einem JSON-Objekt: {"has_recipe": true} oder {"has_recipe": false}
Text:
"""
def _get_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=get_settings().openai_api_key)
def _openai_error(e: Exception) -> RuntimeError:
if isinstance(e, AuthenticationError):
return RuntimeError("OpenAI API-Schlüssel ungültig oder abgelaufen.")
if isinstance(e, RateLimitError):
return RuntimeError("OpenAI Rate-Limit erreicht. Bitte kurz warten und erneut versuchen.")
if isinstance(e, APIConnectionError):
return RuntimeError("OpenAI nicht erreichbar. Internetverbindung prüfen.")
return RuntimeError(f"OpenAI-Fehler: {e}")
async def check_caption_has_recipe(text: str) -> bool:
"""Check if a video description/caption contains a complete recipe."""
client = _get_client()
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": _CAPTION_CHECK_PROMPT + text[:3000]}
],
max_tokens=20,
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content or "{}")
return bool(data.get("has_recipe", False))
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
raise _openai_error(e) from e
except Exception as e:
log.warning("Caption check failed: %s", e)
return False
async def transcribe_audio(audio_path: Path) -> str:
"""Transcribe audio file with Whisper API."""
client = _get_client()
try:
with open(audio_path, "rb") as f:
transcript = await client.audio.transcriptions.create(
model="whisper-1",
file=f,
language="de",
response_format="text",
)
return str(transcript)
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
raise _openai_error(e) from e
async def extract_recipe_from_text(text: str, source_url: str) -> Recipe:
"""Use GPT-4o-mini to extract a structured recipe from text."""
client = _get_client()
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": _RECIPE_SYSTEM_PROMPT},
{"role": "user", "content": text[:12000]},
],
response_format={"type": "json_object"},
temperature=0.1,
)
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
raise _openai_error(e) from e
raw = resp.choices[0].message.content or "{}"
data = json.loads(raw)
return _gpt_json_to_recipe(data, source_url)
async def estimate_nutrition_gpt(ingredients: list[Ingredient], servings: int) -> NutritionInfo:
"""Estimate kcal using GPT-4o-mini as last resort."""
client = _get_client()
ing_text = "\n".join(
f"- {i.amount or ''} {i.unit or ''} {i.name} {('(' + i.note + ')') if i.note else ''}".strip()
for i in ingredients
)
prompt = (
f"Schätze die Gesamtkalorienzahl und Kalorien pro Portion für folgende Zutaten "
f"({servings} Portionen):\n{ing_text}\n\n"
'Antworte NUR mit JSON: {"kcal_total": number, "kcal_per_serving": number}'
)
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0,
)
data = json.loads(resp.choices[0].message.content or "{}")
return NutritionInfo(
kcal_per_serving=data.get("kcal_per_serving"),
kcal_total=data.get("kcal_total"),
servings=servings,
source="gpt4_estimate",
)
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
log.warning("GPT nutrition estimate failed (API): %s", _openai_error(e))
return NutritionInfo(source="gpt4_estimate")
except Exception as e:
log.warning("GPT nutrition estimate failed: %s", e)
return NutritionInfo(source="gpt4_estimate")
def _gpt_json_to_recipe(data: dict, source_url: str) -> Recipe:
ingredients = [
Ingredient(
name=i.get("name", ""),
amount=i.get("amount"),
unit=i.get("unit"),
note=i.get("note"),
)
for i in data.get("ingredients", [])
if i.get("name")
]
nutrition = None
kcal = data.get("kcal_per_serving")
if kcal:
try:
nutrition = NutritionInfo(kcal_per_serving=float(kcal), source="extracted")
except (ValueError, TypeError):
pass
return Recipe(
title=data.get("title", "Unbekanntes Rezept"),
description=data.get("description"),
image_url=None,
source_url=source_url,
servings=data.get("servings"),
prep_time_minutes=data.get("prep_time_minutes"),
cook_time_minutes=data.get("cook_time_minutes"),
ingredients=ingredients,
steps=data.get("steps", []),
nutrition=nutrition,
tags=data.get("tags", [])[:10],
)
+146
View File
@@ -0,0 +1,146 @@
"""Tandoor REST API client."""
from __future__ import annotations
import logging
from typing import Optional
import httpx
from app.config import get_settings
from app.models import (
Ingredient,
Recipe,
TandoorIngredient,
TandoorRecipe,
TandoorStep,
)
log = logging.getLogger(__name__)
def recipe_to_tandoor(recipe: Recipe) -> TandoorRecipe:
"""Convert internal Recipe to Tandoor-compatible import format."""
working_time = (recipe.prep_time_minutes or 0) + (recipe.cook_time_minutes or 0)
# All ingredients go into step[0] (Tandoor convention)
tandoor_ingredients = [_to_tandoor_ingredient(ing) for ing in recipe.ingredients]
# Build steps: first step carries all ingredients, subsequent steps are plain text
steps: list[TandoorStep] = []
for i, step_text in enumerate(recipe.steps):
step = TandoorStep(
instruction=step_text,
ingredients=tandoor_ingredients if i == 0 else [],
time=0,
)
steps.append(step)
if not steps:
# If no steps, create a placeholder with all ingredients
steps = [TandoorStep(instruction="Zubereitung", ingredients=tandoor_ingredients)]
keywords = [{"label": tag, "name": tag} for tag in recipe.tags]
servings_text = "Portionen"
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
kcal = round(recipe.nutrition.kcal_per_serving)
servings_text = f"Portionen (ca. {kcal} kcal)"
return TandoorRecipe(
name=recipe.title,
description=recipe.description or "",
servings=recipe.servings or 1,
working_time=working_time,
source_url=recipe.source_url,
image=recipe.image_url,
steps=steps,
keywords=keywords,
servings_text=servings_text,
)
def _to_tandoor_ingredient(ing: Ingredient) -> TandoorIngredient:
amount = 0.0
if ing.amount:
try:
from app.services.nutrition import _parse_float
val = _parse_float(ing.amount)
amount = val if val is not None else 0.0
except Exception:
amount = 0.0
unit = {"name": ing.unit} if ing.unit else None
return TandoorIngredient(
food={"name": ing.name},
amount=amount,
unit=unit,
note=ing.note or "",
)
async def import_to_tandoor(recipe: Recipe) -> dict:
"""Import a recipe to Tandoor via REST API. Returns created recipe data."""
settings = get_settings()
if not settings.tandoor_api_token:
raise RuntimeError("TANDOOR_API_TOKEN nicht konfiguriert.")
if not settings.tandoor_base_url:
raise RuntimeError("TANDOOR_BASE_URL nicht konfiguriert.")
base_url = settings.tandoor_base_url.rstrip("/")
headers = {
"Authorization": f"Bearer {settings.tandoor_api_token}",
"Content-Type": "application/json",
}
tandoor_recipe = recipe_to_tandoor(recipe)
payload = tandoor_recipe.model_dump(exclude={"image"})
async with httpx.AsyncClient(timeout=30) as client:
# POST recipe
resp = await client.post(
f"{base_url}/api/recipe/",
json=payload,
headers=headers,
)
if resp.status_code not in (200, 201):
raise RuntimeError(
f"Tandoor API Fehler {resp.status_code}: {resp.text[:300]}"
)
created = resp.json()
recipe_id = created.get("id")
# Upload image if available
if recipe.image_url and recipe_id:
await _upload_image(client, base_url, recipe_id, recipe.image_url, settings.tandoor_api_token)
return created
async def _upload_image(
client: httpx.AsyncClient,
base_url: str,
recipe_id: int,
image_url: str,
token: str,
) -> None:
"""Download image from URL and upload to Tandoor."""
try:
img_resp = await client.get(image_url, follow_redirects=True, timeout=15)
img_resp.raise_for_status()
image_data = img_resp.content
# Determine content type
content_type = img_resp.headers.get("content-type", "image/jpeg").split(";")[0]
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(content_type, "jpg")
files = {"image": (f"recipe.{ext}", image_data, content_type)}
patch_resp = await client.patch(
f"{base_url}/api/recipe/{recipe_id}/",
files=files,
headers={"Authorization": f"Bearer {token}"}, # NO Content-Type here
)
if patch_resp.status_code not in (200, 201):
log.warning("Image upload failed: %s", patch_resp.text[:200])
except Exception as e:
log.warning("Could not upload image: %s", e)