Files
2026-07-24 21:37:03 +02:00

799 lines
26 KiB
Python

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