236 lines
8.0 KiB
Python
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
|