Add phase 4: AI cleanup and photo/text import via OpenAI

- recipes/ai.py: Chat Completions with strict JSON schema (Structured
  Outputs), Pydantic re-validation, plausibility clamping, prompt treats
  input as data only
- "Mit KI aufbereiten" on create/edit form returns an editable draft
- Import page: photo/screenshot (vision, downscaled JPEG, not stored) and
  pasted text; drafts pre-fill the recipe form with a preview
- Clear UI messages for timeout, rate limit, quota, auth, model and
  connection errors; without OPENAI_API_KEY AI features show a notice
- Optional per-user and global daily AI limits (default unlimited)
- Tests for response parsing, schema strictness, error mapping, views

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 21:43:30 +01:00
co-authored by Claude Opus 5.5
parent 9256e94286
commit 1bf8ef8b65
17 changed files with 856 additions and 13 deletions
+26 -3
View File
@@ -14,7 +14,7 @@ from django.views.decorators.http import require_GET, require_POST
from core import ratelimit
from . import images
from . import ai, images
from .forms import CommentForm, PhotoUploadForm, RecipeForm, SearchForm
from .models import Comment, Favorite, Recipe, RecipeImage, Tag
@@ -99,25 +99,48 @@ def _render_detail(request, recipe, comment_form=None, status=200):
}, status=status)
def _render_form(request, form, recipe=None, **extra):
context = {"form": form, "is_new": recipe is None, "recipe": recipe,
"images": recipe.images.all() if recipe else [], **extra}
return render(request, "recipes/form.html", context)
def _ai_cleanup(request, recipe=None):
"""'Mit KI aufbereiten' on the manual form: replace the fields with an AI draft (not saved)."""
data = request.POST
try:
draft = ai.structure_text(request.user, ai.form_text_for_ai(data))
except ai.AIError as error:
messages.error(request, error.user_message)
return _render_form(request, RecipeForm(initial=data.dict(), instance=recipe), recipe)
keep = {key: data.get(key, "") for key in ("visibility", "source_url") if data.get(key)}
form = RecipeForm(initial={**draft.to_form_initial(), **keep}, instance=recipe)
return _render_form(request, form, recipe, ai_draft=draft)
def recipe_create(request):
if request.method == "POST" and "ai" in request.POST:
return _ai_cleanup(request)
form = RecipeForm(request.POST or None, request.FILES or None)
if request.method == "POST" and form.is_valid():
recipe = form.save(owner=request.user)
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
messages.success(request, "Rezept gespeichert.")
return redirect("recipes:detail", pk=recipe.pk)
return render(request, "recipes/form.html", {"form": form, "is_new": True})
return _render_form(request, form)
def recipe_edit(request, pk):
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
if request.method == "POST" and "ai" in request.POST:
return _ai_cleanup(request, recipe)
form = RecipeForm(request.POST or None, request.FILES or None, instance=recipe)
if request.method == "POST" and form.is_valid():
recipe = form.save()
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
messages.success(request, "Änderungen gespeichert.")
return redirect("recipes:detail", pk=recipe.pk)
return render(request, "recipes/form.html", {"form": form, "recipe": recipe, "images": recipe.images.all()})
return _render_form(request, form, recipe)
def recipe_delete(request, pk):