mirror of
https://github.com/Andre-Bzk/Rezept0r.git
synced 2026-11-15 15:43:04 +01:00
Prototyp
This commit is contained in:
@@ -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],
|
||||
)
|
||||
Reference in New Issue
Block a user