Files
Rezept0r/app/services/openai_service.py
T
2026-03-29 20:52:20 +02:00

184 lines
6.5 KiB
Python

"""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],
)