from __future__ import annotations import os import traceback from pathlib import Path from typing import Any, Literal from fastapi import FastAPI, HTTPException, Query from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from .auth import basic_auth_middleware from .models import AnalyzeRequest, ImportRequest, RecipeRequest, RecipeSpec from .openai_service import analyze_with_openai from .quality import local_quality_warnings from .source_extractor import extract_source from .storage import create_run_id, write_json from .tandoor_service import import_recipe, resolve_recipe, search_tandoor_objects BASE_DIR = Path(__file__).resolve().parent.parent STATIC_DIR = BASE_DIR / "static" app = FastAPI( title="Tandoor AI Web Import", version="1.1.0", docs_url="/api/docs", redoc_url=None, ) app.middleware("http")(basic_auth_middleware) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/") def index(): return FileResponse(STATIC_DIR / "index.html") @app.get("/api/health") def health() -> dict[str, Any]: return { "status": "ok", "version": app.version, "openai_configured": bool(os.environ.get("OPENAI_API_KEY")) and bool(os.environ.get("OPENAI_MODEL")), "tandoor_configured": bool(os.environ.get("TANDOOR_URL")) and bool(os.environ.get("TANDOOR_TOKEN")), "authentication_enabled": bool(os.environ.get("APP_PASSWORD")), } def combined_warnings( recipe: RecipeSpec, resolution: dict[str, Any], ) -> list[dict[str, Any]]: warnings = [item.model_dump() for item in recipe.warnings] warnings.extend(local_quality_warnings(recipe)) for mapping in resolution["mappings"]: for kind, label in (("food", "Food"), ("unit", "Einheit")): item = mapping.get(kind) if not item: continue if item["blocking"]: warnings.append( { "severity": "blocking", "code": f"{kind}_resolution", "message": ( f"{mapping['step_name']}: {label} " f"„{item['requested']}“ ist noch nicht zugeordnet. " "Im Dropdown einen vorhandenen Eintrag wählen oder " "bewusst einen neuen anlegen." ), } ) elif item.get("status") == "create": warnings.append( { "severity": "warning", "code": f"{kind}_will_be_created", "message": ( f"{mapping['step_name']}: {label} " f"„{item['lookup_name']}“ wird beim Import neu in " "Tandoor angelegt." ), } ) elif item.get("needs_review"): resolved = item.get("resolved") or {} warnings.append( { "severity": "warning", "code": f"{kind}_suggestion", "message": ( f"{mapping['step_name']}: Für {label} " f"„{item['requested']}“ ist „{resolved.get('name', '?')}“ " "vorausgewählt. Bitte das Dropdown kurz prüfen." ), } ) # Gleiche Meldungen zusammenfassen, ohne ihre Reihenfolge zu verändern. deduplicated: list[dict[str, Any]] = [] seen: set[tuple[str, str, str]] = set() for item in warnings: key = (item["severity"], item["code"], item["message"]) if key not in seen: seen.add(key) deduplicated.append(item) return deduplicated def validate_and_resolve(recipe: RecipeSpec) -> dict[str, Any]: resolution = resolve_recipe(recipe) warnings = combined_warnings(recipe, resolution) blocking = resolution["blocking"] or any( item["severity"] == "blocking" for item in warnings ) # Die sichtbare Vorauswahl wird auch im bearbeitbaren JSON festgehalten. # Dadurch bleibt die Zuordnung zwischen Prüfung und Import stabil, ohne den # ursprünglichen Food-Namen oder original_text zu überschreiben. recipe_data = recipe.model_dump(mode="json") for mapping in resolution["mappings"]: ingredient = recipe_data["steps"][mapping["step_index"]]["ingredients"][ mapping["ingredient_index"] ] food = mapping.get("food") if ( food and food.get("selected_id") and not ingredient.get("create_food") and ingredient.get("preferred_food_id") is None ): ingredient["preferred_food_id"] = food["selected_id"] unit = mapping.get("unit") if ( unit and unit.get("selected_id") and not ingredient.get("create_unit") and ingredient.get("preferred_unit_id") is None ): ingredient["preferred_unit_id"] = unit["selected_id"] return { "recipe": recipe_data, "resolution": resolution, "warnings": warnings, "blocking": blocking, } @app.get("/api/tandoor/search") def search_tandoor( kind: Literal["food", "unit"] = Query(...), q: str = Query(..., min_length=1, max_length=200), ): try: return search_tandoor_objects(kind, q) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/analyze") def analyze(request: AnalyzeRequest): run_id = create_run_id() try: source = extract_source(request.url) write_json(run_id, "01-source.json", source.to_dict()) recipe, ai_metadata = analyze_with_openai(source) write_json(run_id, "02-openai-metadata.json", ai_metadata) write_json(run_id, "03-ai-recipe.json", recipe.model_dump(mode="json")) result = validate_and_resolve(recipe) write_json(run_id, "04-resolution.json", result) return {"run_id": run_id, **result} except Exception as exc: try: write_json( run_id, "ERROR.json", {"error": str(exc), "traceback": traceback.format_exc()}, ) except Exception: pass raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/runs/{run_id}/validate") def validate(run_id: str, request: RecipeRequest): try: result = validate_and_resolve(request.recipe) write_json( run_id, "05-edited-recipe.json", request.recipe.model_dump(mode="json"), ) write_json(run_id, "06-edited-resolution.json", result) return {"run_id": run_id, **result} except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/runs/{run_id}/import") def apply_import(run_id: str, request: ImportRequest): try: validation = validate_and_resolve(request.recipe) write_json(run_id, "07-import-validation.json", validation) if validation["blocking"]: raise RuntimeError( "Der Import ist wegen blockierender Warnungen oder Zuordnungen gesperrt." ) result = import_recipe( request.recipe, validation["resolution"], import_image=request.import_image, force_duplicate=request.force_duplicate, ) write_json(run_id, "08-import-result.json", result) return {"run_id": run_id, **result} except Exception as exc: try: write_json( run_id, "IMPORT-ERROR.json", {"error": str(exc), "traceback": traceback.format_exc()}, ) except Exception: pass raise HTTPException(status_code=400, detail=str(exc)) from exc