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

236 lines
8.0 KiB
Python

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