from __future__ import annotations import copy import os import re import time import unicodedata from difflib import SequenceMatcher from typing import Any, Literal from urllib.parse import quote import requests from .models import RecipeSpec UNIT_NORMALIZATION = { "teelöffel": "TL", "teeloeffel": "TL", "tsp": "TL", "teaspoon": "TL", "teaspoons": "TL", "esslöffel": "EL", "essloeffel": "EL", "tbsp": "EL", "tablespoon": "EL", "tablespoons": "EL", "gramm": "g", "grams": "g", "gram": "g", "kilogramm": "kg", "kilograms": "kg", "milliliter": "ml", "milliliters": "ml", "liter": "l", "litre": "l", "stück": "Stück", "stueck": "Stück", "piece": "Stück", "pieces": "Stück", "pinch": "Prise", "can": "Dose", } def folded(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "").strip()).casefold() def comparable(value: Any) -> str: text = folded(value) text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue") text = text.replace("ß", "ss") text = "".join( char for char in unicodedata.normalize("NFKD", text) if not unicodedata.combining(char) ) return re.sub(r"[^a-z0-9]+", " ", text).strip() def results_from(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if isinstance(payload, dict) and isinstance(payload.get("results"), list): return [item for item in payload["results"] if isinstance(item, dict)] return [] class TandoorClient: def __init__(self) -> None: base_url = os.environ.get("TANDOOR_URL", "").strip() token = os.environ.get("TANDOOR_TOKEN", "").strip() scheme = os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer").strip() if not base_url or not token: raise RuntimeError("TANDOOR_URL oder TANDOOR_TOKEN ist nicht gesetzt.") self.base_url = base_url.rstrip("/") self.timeout = float(os.environ.get("TANDOOR_TIMEOUT", "45")) self.verify = os.environ.get("TANDOOR_VERIFY_TLS", "true").casefold() not in { "0", "false", "no", } self.session = requests.Session() self.session.headers.update( { "Authorization": f"{scheme} {token}", "Accept": "application/json", "User-Agent": "tandoor-ai-web-import/1.1", } ) self._object_cache: dict[str, list[dict[str, Any]]] = {} def request(self, method: str, path: str, **kwargs: Any) -> requests.Response: url = path if path.startswith(("http://", "https://")) else f"{self.base_url}/{path.lstrip('/')}" response = self.session.request( method, url, timeout=self.timeout, verify=self.verify, **kwargs, ) if not response.ok: raise RuntimeError( f"Tandoor {method} {path}: HTTP {response.status_code}\n" f"{response.text[:4000]}" ) return response def get_json(self, path: str) -> Any: response = self.request("GET", path) try: return response.json() finally: response.close() def post_json(self, path: str, payload: Any) -> Any: response = self.request("POST", path, json=payload) try: return response.json() finally: response.close() def put_image_url(self, recipe_id: int, image_url: str) -> Any: response = self.request( "PUT", f"api/recipe/{recipe_id}/image/", files={"image_url": (None, image_url)}, ) try: return response.json() finally: response.close() def delete(self, path: str) -> None: response = self.request("DELETE", path) response.close() def list_objects( self, endpoint: str, *, force_refresh: bool = False, ) -> list[dict[str, Any]]: if endpoint in self._object_cache and not force_refresh: return self._object_cache[endpoint] items: list[dict[str, Any]] = [] next_url: str | None = f"api/{endpoint}/?page_size=500" seen_urls: set[str] = set() pages = 0 while next_url and next_url not in seen_urls and pages < 30: seen_urls.add(next_url) payload = self.get_json(next_url) items.extend(results_from(payload)) next_url = payload.get("next") if isinstance(payload, dict) else None pages += 1 deduplicated: dict[int, dict[str, Any]] = {} for item in items: object_id = item.get("id") if isinstance(object_id, int): deduplicated[object_id] = item result = list(deduplicated.values()) self._object_cache[endpoint] = result return result def forget_cache(self, endpoint: str) -> None: self._object_cache.pop(endpoint, None) def _last_word_forms(word: str) -> set[str]: word = word.strip() if not word: return set() forms = {word} if len(word) <= 2: return forms # Häufige deutsche Singular-/Pluralformen. Diese Heuristik dient nur der # Kandidatensuche. Die endgültige Auswahl bleibt im Dropdown sichtbar. if word.endswith("eln"): forms.add(word[:-1]) # Kartoffeln -> Kartoffel if word.endswith("ern"): forms.add(word[:-1]) if word.endswith("en") and len(word) > 4: forms.add(word[:-2]) forms.add(word[:-1]) if word.endswith("n") and len(word) > 4: forms.add(word[:-1]) if word.endswith("e"): forms.add(word + "n") else: forms.update({word + "e", word + "en", word + "n", word + "s"}) return {form for form in forms if len(form) >= 3} def search_variants(name: str) -> set[str]: normalized = comparable(name) if not normalized: return set() words = normalized.split() variants = {normalized} for last in _last_word_forms(words[-1]): variants.add(" ".join([*words[:-1], last])) variants.add(last) for word in words: variants.update(_last_word_forms(word)) return {item for item in variants if item} def _object_names(obj: dict[str, Any]) -> list[str]: return [ value for value in ( obj.get("name"), obj.get("plural_name"), obj.get("full_name"), ) if value ] def _score_candidate( requested_name: str, obj: dict[str, Any], *, object_type: str, ) -> tuple[float, str]: requested = comparable(requested_name) variants = search_variants(requested_name) best_score = 0.0 best_reason = "ähnlich" for raw_candidate in _object_names(obj): candidate = comparable(raw_candidate) if not candidate: continue if candidate == requested: score, reason = 100.0, "exakt" elif candidate in variants: score, reason = 96.0, "Singular/Plural" elif requested in search_variants(raw_candidate): score, reason = 95.0, "Singular/Plural" elif min(len(candidate), len(requested)) >= 4 and ( candidate in requested or requested in candidate ): short = min(len(candidate), len(requested)) long = max(len(candidate), len(requested)) score, reason = 84.0 + (short / long) * 7.0, "Teilwort" else: ratio = SequenceMatcher(None, requested, candidate).ratio() requested_words = set(requested.split()) candidate_words = set(candidate.split()) overlap = ( len(requested_words & candidate_words) / max(1, len(requested_words | candidate_words)) ) score = max(ratio * 78.0, overlap * 82.0) reason = "ähnlich" if score > best_score: best_score, best_reason = score, reason if object_type == "Food" and obj.get("properties"): best_score += 0.4 return min(best_score, 100.0), best_reason def _candidate_summary( objects: list[dict[str, Any]], requested_name: str, *, object_type: str, limit: int = 25, ) -> list[dict[str, Any]]: scored = [] for obj in objects: score, reason = _score_candidate( requested_name, obj, object_type=object_type, ) if score < 45.0: continue scored.append((score, obj, reason)) scored.sort( key=lambda item: ( -item[0], -int(bool(item[1].get("properties"))), comparable(item[1].get("name")), ) ) return [ { "id": obj.get("id"), "name": obj.get("name"), "plural_name": obj.get("plural_name"), "full_name": obj.get("full_name"), "has_properties": bool(obj.get("properties")), "score": round(score, 1), "reason": reason, } for score, obj, reason in scored[:limit] if isinstance(obj.get("id"), int) ] def _find_by_id(objects: list[dict[str, Any]], object_id: int) -> dict[str, Any] | None: return next((obj for obj in objects if obj.get("id") == object_id), None) def _find_exact(objects: list[dict[str, Any]], name: str) -> dict[str, Any] | None: target = comparable(name) for obj in objects: if target in {comparable(value) for value in _object_names(obj)}: return obj return None def resolve_object( client: TandoorClient, endpoint: str, requested_name: str, preferred_id: int | None, *, object_type: Literal["Food", "Unit"], create_requested: bool = False, plural_name: str | None = None, normalize_unit: bool = False, ) -> dict[str, Any]: lookup_name = requested_name.strip() normalized_from = None if normalize_unit: canonical = UNIT_NORMALIZATION.get(folded(lookup_name)) if canonical and canonical != lookup_name: normalized_from = lookup_name lookup_name = canonical objects = client.list_objects(endpoint) candidates = _candidate_summary( objects, lookup_name, object_type=object_type, ) if create_requested: return { "status": "create", "requested": requested_name, "lookup_name": lookup_name, "normalized_from": normalized_from, "resolved": { "name": lookup_name, "plural_name": plural_name, "create": True, "endpoint": endpoint, }, "selected_id": None, "candidates": candidates, "blocking": False, "needs_review": True, "message": f"{object_type} wird beim Import neu angelegt.", } if preferred_id is not None: obj = _find_by_id(objects, preferred_id) if obj is None: try: obj = client.get_json(f"api/{endpoint}/{preferred_id}/") except Exception as exc: return { "status": "missing_preferred", "requested": requested_name, "lookup_name": lookup_name, "normalized_from": normalized_from, "resolved": None, "selected_id": None, "candidates": candidates, "blocking": True, "needs_review": True, "message": f"Die ausgewählte ID {preferred_id} ist nicht erreichbar: {exc}", } return { "status": "preferred", "requested": requested_name, "lookup_name": lookup_name, "normalized_from": normalized_from, "resolved": {"id": obj["id"], "name": obj["name"]}, "selected_id": obj["id"], "candidates": candidates, "blocking": False, "needs_review": False, } if candidates: recommended = candidates[0] close_second = len(candidates) > 1 and ( recommended["score"] - candidates[1]["score"] < 2.0 ) status = { "exakt": "exact", "Singular/Plural": "variant", "Teilwort": "partial", }.get(recommended["reason"], "suggested") needs_review = status not in {"exact", "variant"} or close_second return { "status": status, "requested": requested_name, "lookup_name": lookup_name, "normalized_from": normalized_from, "resolved": { "id": recommended["id"], "name": recommended["name"], }, "selected_id": recommended["id"], "candidates": candidates, "blocking": False, "needs_review": needs_review, "message": ( "Ähnlicher Treffer wurde vorausgewählt; bitte im Dropdown prüfen." if needs_review else None ), } return { "status": "missing", "requested": requested_name, "lookup_name": lookup_name, "normalized_from": normalized_from, "resolved": None, "selected_id": None, "candidates": [], "blocking": True, "needs_review": True, "message": ( f"Kein passender {object_type}-Eintrag gefunden. " "Einen Namen eintragen und als neuen Eintrag auswählen." ), } def resolve_keyword(client: TandoorClient, name: str) -> dict[str, Any]: objects = client.list_objects("keyword") exact = _find_exact(objects, name) if exact: return {"id": exact["id"], "name": exact["name"]} return {"name": name} def amount_note(amount: float, amount_max: float | None, unit_name: str | None) -> str: if amount_max is None or amount_max <= amount: return "" unit = f" {unit_name}" if unit_name else "" return f"Mengenbereich: bis {amount_max:g}{unit}" def resolve_recipe( recipe: RecipeSpec, *, client: TandoorClient | None = None, ) -> dict[str, Any]: client = client or TandoorClient() mappings: list[dict[str, Any]] = [] steps_payload: list[dict[str, Any]] = [] blocking = False for step_index, step in enumerate(recipe.steps): ingredients_payload = [] for ingredient_index, ingredient in enumerate(step.ingredients): food_resolution = resolve_object( client, "food", ingredient.food_name, ingredient.preferred_food_id, object_type="Food", create_requested=ingredient.create_food, plural_name=ingredient.food_plural_name, ) unit_resolution = None if ingredient.unit_name: unit_resolution = resolve_object( client, "unit", ingredient.unit_name, ingredient.preferred_unit_id, object_type="Unit", create_requested=ingredient.create_unit, plural_name=ingredient.unit_plural_name, normalize_unit=True, ) row_blocking = food_resolution["blocking"] or bool( unit_resolution and unit_resolution["blocking"] ) blocking = blocking or row_blocking mappings.append( { "step_index": step_index, "ingredient_index": ingredient_index, "step_name": step.name, "original_text": ingredient.original_text, "food": food_resolution, "unit": unit_resolution, "blocking": row_blocking, } ) resolved_food = food_resolution.get("resolved") resolved_unit = unit_resolution.get("resolved") if unit_resolution else None if not resolved_food or (ingredient.unit_name and not resolved_unit): continue note_parts = [ingredient.note.strip()] range_note = amount_note( ingredient.amount, ingredient.amount_max, ( resolved_unit.get("name") if isinstance(resolved_unit, dict) else None ), ) if range_note: note_parts.append(range_note) ingredients_payload.append( { "food": resolved_food, "unit": resolved_unit, "amount": ingredient.amount, "note": "; ".join(part for part in note_parts if part), "order": ingredient_index, "is_header": False, "no_amount": ingredient.no_amount, "original_text": ingredient.original_text, } ) steps_payload.append( { "name": step.name, "instruction": step.instruction, "ingredients": ingredients_payload, "time": step.time, "order": step_index, "show_as_header": True, "step_recipe": None, "show_ingredients_table": True, } ) keywords = [resolve_keyword(client, name) for name in recipe.keywords] payload = { "name": recipe.name, "description": recipe.description, "keywords": keywords, "steps": steps_payload, "working_time": recipe.working_time, "waiting_time": recipe.waiting_time, "source_url": recipe.source_url, "internal": True, "show_ingredient_overview": True, "servings": recipe.servings, "servings_text": recipe.servings_text, "diameter": 0, "diameter_text": "", "private": False, "shared": [], } return { "blocking": blocking, "mappings": mappings, "payload": payload if not blocking else None, } def search_tandoor_objects( kind: Literal["food", "unit"], query: str, ) -> dict[str, Any]: client = TandoorClient() endpoint = kind object_type = "Food" if kind == "food" else "Unit" candidates = _candidate_summary( client.list_objects(endpoint), query, object_type=object_type, limit=40, ) return { "kind": kind, "query": query, "variants": sorted(search_variants(query)), "candidates": candidates, } def find_duplicates( recipe: RecipeSpec, *, client: TandoorClient | None = None, ) -> list[dict[str, Any]]: client = client or TandoorClient() payload = client.get_json(f"api/recipe/?query={quote(recipe.name)}&page_size=100") duplicates = [] for overview in results_from(payload): recipe_id = overview.get("id") if recipe_id is None: continue detail = client.get_json(f"api/recipe/{recipe_id}/") same_name = folded(detail.get("name")) == folded(recipe.name) same_source = ( str(detail.get("source_url") or "").rstrip("/") == recipe.source_url.rstrip("/") ) if same_name or same_source: duplicates.append( { "id": recipe_id, "name": detail.get("name"), "source_url": detail.get("source_url"), } ) return duplicates def _create_named_object( client: TandoorClient, endpoint: Literal["food", "unit"], name: str, plural_name: str | None, ) -> tuple[dict[str, Any], bool]: # Noch einmal unmittelbar vor dem POST prüfen, damit parallele Läufe keine # Dublette erzeugen. Tandoor führt zusätzlich selbst get_or_create aus. existing = _find_exact(client.list_objects(endpoint, force_refresh=True), name) if existing: return {"id": existing["id"], "name": existing["name"]}, False payload: dict[str, Any] = {"name": name.strip()} if plural_name and plural_name.strip(): payload["plural_name"] = plural_name.strip() created = client.post_json(f"api/{endpoint}/", payload) if not isinstance(created, dict) or not isinstance(created.get("id"), int): raise RuntimeError( f"Tandoor hat für den neuen {endpoint}-Eintrag keine gültige ID geliefert." ) client.forget_cache(endpoint) return {"id": created["id"], "name": created.get("name", name)}, True def _materialize_created_objects( client: TandoorClient, payload: dict[str, Any], ) -> tuple[dict[str, Any], list[dict[str, Any]]]: output = copy.deepcopy(payload) created_objects: list[dict[str, Any]] = [] memo: dict[tuple[str, str, str], dict[str, Any]] = {} for step in output.get("steps", []): for ingredient in step.get("ingredients", []): for field, endpoint in (("food", "food"), ("unit", "unit")): obj = ingredient.get(field) if not isinstance(obj, dict) or not obj.get("create"): continue name = str(obj.get("name") or "").strip() plural_name = str(obj.get("plural_name") or "").strip() or None key = (endpoint, comparable(name), comparable(plural_name)) if key not in memo: resolved, was_created = _create_named_object( client, endpoint, # type: ignore[arg-type] name, plural_name, ) memo[key] = resolved if was_created: created_objects.append( { "endpoint": endpoint, "id": resolved["id"], "name": resolved["name"], } ) ingredient[field] = memo[key] return output, created_objects def _semantic_steps(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: output = [] for step in steps: ingredients = [] for item in step.get("ingredients", []): food = item.get("food") or {} unit = item.get("unit") or {} ingredients.append( { "food": folded(food.get("name")), "unit": folded(unit.get("name")), "amount": round(float(item.get("amount") or 0), 8), "note": str(item.get("note") or "").strip(), "no_amount": bool(item.get("no_amount", False)), } ) output.append( { "name": str(step.get("name") or "").strip(), "instruction": str(step.get("instruction") or "").strip(), "ingredients": ingredients, } ) return output def import_recipe( recipe: RecipeSpec, resolution: dict[str, Any], *, import_image: bool, force_duplicate: bool, ) -> dict[str, Any]: if resolution["blocking"] or not resolution.get("payload"): raise RuntimeError("Der Import ist wegen ungeklärter Zuordnungen blockiert.") client = TandoorClient() duplicates = find_duplicates(recipe, client=client) if duplicates and not force_duplicate: return { "status": "duplicate", "duplicates": duplicates, "recipe_id": None, "recipe_url": None, "created_objects": [], } created_id: int | None = None created_objects: list[dict[str, Any]] = [] rollback_errors: list[str] = [] try: final_payload, created_objects = _materialize_created_objects( client, resolution["payload"], ) created = client.post_json("api/recipe/", final_payload) created_id = created.get("id") if not isinstance(created_id, int): raise RuntimeError("Tandoor hat keine gültige Rezept-ID geliefert.") image_result = None if import_image and recipe.image_url: image_result = client.put_image_url(created_id, recipe.image_url) time.sleep(0.2) verified = client.get_json(f"api/recipe/{created_id}/") expected = _semantic_steps(final_payload["steps"]) actual = _semantic_steps(verified.get("steps", [])) if expected != actual: raise RuntimeError( "Die Nachprüfung der Schritte und Zutaten ist fehlgeschlagen." ) return { "status": "imported", "recipe_id": created_id, "recipe_url": f"{client.base_url}/recipe/{created_id}", "verified": verified, "image_result": image_result, "duplicates": [], "created_objects": created_objects, } except Exception as original_error: if created_id is not None: try: client.delete(f"api/recipe/{created_id}/") except Exception as exc: rollback_errors.append(f"Rezept {created_id}: {exc}") # Nur Objekte entfernen, die dieser Lauf nachweislich neu erstellt hat. for obj in reversed(created_objects): try: client.delete(f"api/{obj['endpoint']}/{obj['id']}/") except Exception as exc: rollback_errors.append( f"{obj['endpoint']} {obj['id']} ({obj['name']}): {exc}" ) if rollback_errors: raise RuntimeError( f"{original_error}\nRollback unvollständig:\n- " + "\n- ".join(rollback_errors) ) from original_error raise