Files
Kochbuch/recipes/import_views.py
T
Kyle DammannandClaude Opus 5.5 1bf8ef8b65 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]>
2026-10-30 21:43:30 +01:00

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)