- 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]>
84 lines
3.2 KiB
Python
84 lines
3.2 KiB
Python
"""Recipe import: photo/screenshot (AI vision), pasted text (AI) and links.
|
|
|
|
Every path ends in the normal recipe form, pre-filled as an editable draft and posted to
|
|
recipes:create. Nothing is saved before the user presses "Speichern".
|
|
"""
|
|
|
|
from django import forms
|
|
from django.contrib import messages
|
|
from django.shortcuts import redirect, render
|
|
from django.urls import reverse
|
|
from django.views.decorators.http import require_POST
|
|
|
|
from . import ai, images
|
|
from .forms import MultipleFileInput, MultipleImageField, RecipeForm
|
|
|
|
|
|
class PhotoImportForm(forms.Form):
|
|
photos = MultipleImageField(
|
|
label="Foto oder Screenshot",
|
|
widget=MultipleFileInput(attrs={"accept": "image/*"}),
|
|
help_text=f"Bis zu {ai.MAX_IMAGES} Bilder vom selben Rezept, z. B. Kochbuchseite oder Screenshot.",
|
|
)
|
|
|
|
def clean_photos(self):
|
|
photos = self.cleaned_data["photos"]
|
|
if not photos:
|
|
raise forms.ValidationError("Bitte wähle ein Bild aus.")
|
|
if len(photos) > ai.MAX_IMAGES:
|
|
raise forms.ValidationError(f"Höchstens {ai.MAX_IMAGES} Bilder.")
|
|
return photos
|
|
|
|
|
|
class TextImportForm(forms.Form):
|
|
text = forms.CharField(
|
|
label="Rezepttext",
|
|
max_length=ai.MAX_INPUT_CHARS,
|
|
widget=forms.Textarea(attrs={"rows": 8, "placeholder": "Rezept hier einfügen, z. B. die Beschreibung eines Instagram-Beitrags"}),
|
|
)
|
|
source_url = forms.URLField(label="Quelle (optional)", required=False, max_length=1000, assume_scheme="https")
|
|
|
|
|
|
def render_draft(request, initial, notice, ai_draft=None, extra_context=None):
|
|
"""Show the recipe form filled with a draft; it posts to recipes:create."""
|
|
form = RecipeForm(initial=initial)
|
|
return render(request, "recipes/form.html", {
|
|
"form": form, "is_new": True, "draft_notice": notice, "ai_draft": ai_draft,
|
|
"form_action": reverse("recipes:create"), **(extra_context or {}),
|
|
})
|
|
|
|
|
|
def import_start(request, photo_form=None, text_form=None):
|
|
return render(request, "recipes/import.html", {
|
|
"photo_form": photo_form or PhotoImportForm(),
|
|
"text_form": text_form or TextImportForm(),
|
|
})
|
|
|
|
|
|
@require_POST
|
|
def import_photo(request):
|
|
form = PhotoImportForm(request.POST, request.FILES)
|
|
if not form.is_valid():
|
|
return import_start(request, photo_form=form)
|
|
try:
|
|
jpegs = [images.prepare_for_ai(f) for f in form.cleaned_data["photos"]]
|
|
draft = ai.extract_from_images(request.user, jpegs)
|
|
except (ai.AIError, images.ImageError) as error:
|
|
messages.error(request, getattr(error, "user_message", str(error)))
|
|
return redirect("recipes:import")
|
|
return render_draft(request, draft.to_form_initial(), "Aus dem Bild erkannt", ai_draft=draft)
|
|
|
|
|
|
@require_POST
|
|
def import_text(request):
|
|
form = TextImportForm(request.POST)
|
|
if not form.is_valid():
|
|
return import_start(request, text_form=form)
|
|
try:
|
|
draft = ai.structure_text(request.user, form.cleaned_data["text"])
|
|
except ai.AIError as error:
|
|
messages.error(request, error.user_message)
|
|
return import_start(request, text_form=form)
|
|
initial = {**draft.to_form_initial(), "source_url": form.cleaned_data["source_url"]}
|
|
return render_draft(request, initial, "Aus dem Text erkannt", ai_draft=draft)
|