kitchen/backend/api/food_flags.py
2026-07-12 12:16:28 +00:00

2309 lines
88 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Food Flag API — categories, flags, line item flagging + latching, recipe flag propagation,
allergen keyword suggestions, and label OCR scanning.
"""
import logging
import os
import re
import uuid
from datetime import datetime
from typing import Optional
import aiofiles
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from database import get_db
from models.user import User
from models.food_flag import FoodFlagCategory, FoodFlag, LineItemFlag, RecipeFlag, RecipeFlagOverride, AllergenKeyword, BrakesProductCache
from models.ingredient import Ingredient, IngredientFlag, IngredientFlagNone, IngredientFlagDismissal
from models.line_item import LineItem
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeTextFlagDismissal
from models.settings import KitchenSettings
from auth import get_current_user, require_cap
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic schemas ─────────────────────────────────────────────────────────
class CategoryCreate(BaseModel):
name: str
propagation_type: str = "contains" # "contains" | "suitable_for"
required: bool = False
sort_order: int = 0
class CategoryUpdate(BaseModel):
name: Optional[str] = None
propagation_type: Optional[str] = None
required: Optional[bool] = None
sort_order: Optional[int] = None
class FlagCreate(BaseModel):
category_id: int
name: str
code: Optional[str] = None
icon: Optional[str] = None
sort_order: int = 0
class FlagUpdate(BaseModel):
name: Optional[str] = None
code: Optional[str] = None
icon: Optional[str] = None
sort_order: Optional[int] = None
class FlagResponse(BaseModel):
id: int
name: str
code: Optional[str] = None
icon: Optional[str] = None
sort_order: int = 0
category_id: int
category_name: str = ""
propagation_type: str = "contains"
class CategoryResponse(BaseModel):
id: int
name: str
propagation_type: str
required: bool = False
sort_order: int
flags: list[FlagResponse] = []
class LineItemFlagSet(BaseModel):
food_flag_ids: list[int]
class LineItemFlagResponse(BaseModel):
id: int
food_flag_id: int
flag_name: str = ""
flag_code: Optional[str] = None
category_name: str = ""
class RecipeFlagState(BaseModel):
food_flag_id: int
flag_name: str = ""
flag_code: Optional[str] = None
flag_icon: Optional[str] = None
category_id: int
category_name: str = ""
propagation_type: str = "contains"
source_type: str = "auto" # "auto" | "manual"
is_active: bool = True
excludable_on_request: bool = False
source_ingredients: list[str] = [] # ingredient names that contribute this flag
class RecipeFlagOverrideLog(BaseModel):
id: int
food_flag_id: int
flag_name: str = ""
action: str
note: str
username: str = ""
created_at: str = ""
class OverrideRequest(BaseModel):
note: str
class ManualFlagAdd(BaseModel):
food_flag_id: int
class ExcludableToggle(BaseModel):
note: str
class MatrixCell(BaseModel):
has_flag: bool = False
is_unassessed: bool = False
is_none: bool = False # "None apply" set for this flag's category
has_open_suggestion: bool = False # unreviewed allergen suggestion exists
class MatrixIngredient(BaseModel):
ingredient_id: int
ingredient_name: str
is_sub_recipe: bool = False
sub_recipe_name: Optional[str] = None
flags: dict[int, MatrixCell] = {} # food_flag_id -> cell state
# ── Seed defaults ────────────────────────────────────────────────────────────
DEFAULT_ALLERGY_FLAGS = [
("Celery", "Ce"), ("Gluten", "Gl"), ("Crustaceans", "Cr"), ("Eggs", "Eg"),
("Fish", "Fi"), ("Lupin", "Lu"), ("Milk", "Mi"), ("Molluscs", "Mo"),
("Mustard", "Mu"), ("Tree Nuts", "TN"), ("Peanuts", "Pn"), ("Sesame", "Se"),
("Soya", "So"), ("Sulphites", "Su"),
]
DEFAULT_DIETARY_FLAGS = [
("Vegetarian", "V"), ("Vegan", "Ve"), ("Pescatarian", "Pe"), ("Gluten-Free", "GF"),
]
@router.post("/seed-defaults")
async def seed_default_flags(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Seed the standard 14 UK allergens + 4 dietary flags. Skips any that already exist."""
import traceback
try:
from migrations.add_allergen_keywords import ALLERGEN_KEYWORDS
kid = user.kitchen_id
created_cats = 0
created_flags = 0
seeded_keywords = 0
# --- Allergy category ---
result = await db.execute(
select(FoodFlagCategory).where(
FoodFlagCategory.kitchen_id == kid,
FoodFlagCategory.name == "Allergy",
)
)
allergy_cat = result.scalar_one_or_none()
if not allergy_cat:
allergy_cat = FoodFlagCategory(
kitchen_id=kid, name="Allergy", propagation_type="contains",
required=True, sort_order=0,
)
db.add(allergy_cat)
await db.flush()
created_cats += 1
for i, (name, code) in enumerate(DEFAULT_ALLERGY_FLAGS):
exists = await db.execute(
select(FoodFlag).where(
FoodFlag.kitchen_id == kid, FoodFlag.name == name,
)
)
if exists.scalar_one_or_none():
continue
db.add(FoodFlag(
category_id=allergy_cat.id, kitchen_id=kid,
name=name, code=code, sort_order=i,
))
created_flags += 1
# --- Dietary category ---
result = await db.execute(
select(FoodFlagCategory).where(
FoodFlagCategory.kitchen_id == kid,
FoodFlagCategory.name == "Dietary",
)
)
dietary_cat = result.scalar_one_or_none()
if not dietary_cat:
dietary_cat = FoodFlagCategory(
kitchen_id=kid, name="Dietary", propagation_type="suitable_for",
required=False, sort_order=1,
)
db.add(dietary_cat)
await db.flush()
created_cats += 1
for i, (name, code) in enumerate(DEFAULT_DIETARY_FLAGS):
exists = await db.execute(
select(FoodFlag).where(
FoodFlag.kitchen_id == kid, FoodFlag.name == name,
)
)
if exists.scalar_one_or_none():
continue
db.add(FoodFlag(
category_id=dietary_cat.id, kitchen_id=kid,
name=name, code=code, sort_order=i,
))
created_flags += 1
await db.flush()
# --- Seed allergen keywords ---
# Flush new flags first, then seed keywords separately to avoid autoflush conflicts
await db.commit()
# Re-fetch all flags for keyword seeding (clean session, no pending objects)
for flag_name, keywords in ALLERGEN_KEYWORDS.items():
flag_result = await db.execute(
select(FoodFlag).where(
FoodFlag.kitchen_id == kid, FoodFlag.name == flag_name,
)
)
flag = flag_result.scalar_one_or_none()
if not flag:
continue
# Get all existing keywords for this flag
existing_result = await db.execute(
select(AllergenKeyword.keyword).where(
AllergenKeyword.kitchen_id == kid,
AllergenKeyword.food_flag_id == flag.id,
)
)
existing_keywords = {row[0] for row in existing_result.all()}
# Add only keywords that don't already exist (preserves manual entries)
seen: set[str] = set()
for kw_str in keywords:
kw = kw_str.lower()
if kw in seen or kw in existing_keywords:
continue
seen.add(kw)
db.add(AllergenKeyword(
kitchen_id=kid, food_flag_id=flag.id,
keyword=kw, is_default=True,
))
seeded_keywords += 1
# Flush after each flag to avoid cross-flag autoflush issues
await db.flush()
await db.commit()
logger.info(f"Seeded defaults for kitchen {kid}: {created_cats} categories, {created_flags} flags, {seeded_keywords} keywords")
return {
"ok": True,
"created_categories": created_cats,
"created_flags": created_flags,
"seeded_keywords": seeded_keywords,
}
except Exception as e:
logger.error(f"seed-defaults failed: {e}\n{traceback.format_exc()}")
raise HTTPException(500, detail=f"Seed failed: {str(e)}")
# ── Category endpoints ───────────────────────────────────────────────────────
@router.get("/categories")
async def list_categories(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(FoodFlagCategory)
.options(selectinload(FoodFlagCategory.flags))
.where(FoodFlagCategory.kitchen_id == user.kitchen_id)
.order_by(FoodFlagCategory.sort_order, FoodFlagCategory.name)
)
cats = result.scalars().all()
return [
CategoryResponse(
id=c.id,
name=c.name,
propagation_type=c.propagation_type,
required=c.required,
sort_order=c.sort_order,
flags=[
FlagResponse(
id=f.id, name=f.name, code=f.code, icon=f.icon,
sort_order=f.sort_order, category_id=c.id,
category_name=c.name, propagation_type=c.propagation_type,
)
for f in sorted(c.flags, key=lambda x: (x.sort_order, x.name))
],
)
for c in cats
]
@router.post("/categories")
async def create_category(
data: CategoryCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if data.propagation_type not in ("contains", "suitable_for"):
raise HTTPException(400, "propagation_type must be 'contains' or 'suitable_for'")
cat = FoodFlagCategory(
kitchen_id=user.kitchen_id,
name=data.name,
propagation_type=data.propagation_type,
required=data.required,
sort_order=data.sort_order,
)
db.add(cat)
await db.commit()
await db.refresh(cat)
return CategoryResponse(id=cat.id, name=cat.name, propagation_type=cat.propagation_type, required=cat.required, sort_order=cat.sort_order)
@router.patch("/categories/{cat_id}")
async def update_category(
cat_id: int,
data: CategoryUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(FoodFlagCategory).where(
FoodFlagCategory.id == cat_id,
FoodFlagCategory.kitchen_id == user.kitchen_id,
)
)
cat = result.scalar_one_or_none()
if not cat:
raise HTTPException(404, "Category not found")
if data.name is not None:
cat.name = data.name
if data.propagation_type is not None:
if data.propagation_type not in ("contains", "suitable_for"):
raise HTTPException(400, "propagation_type must be 'contains' or 'suitable_for'")
cat.propagation_type = data.propagation_type
if data.required is not None:
cat.required = data.required
if data.sort_order is not None:
cat.sort_order = data.sort_order
await db.commit()
return {"ok": True}
@router.delete("/categories/{cat_id}")
async def delete_category(
cat_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(FoodFlagCategory).where(
FoodFlagCategory.id == cat_id,
FoodFlagCategory.kitchen_id == user.kitchen_id,
)
)
cat = result.scalar_one_or_none()
if not cat:
raise HTTPException(404, "Category not found")
# Clean up IngredientFlagNone records for this category
await db.execute(
delete(IngredientFlagNone).where(IngredientFlagNone.category_id == cat_id)
)
try:
await db.delete(cat)
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"Failed to delete category {cat_id}: {e}")
raise HTTPException(500, f"Failed to delete category: {str(e)}")
return {"ok": True}
# ── Flag CRUD ────────────────────────────────────────────────────────────────
@router.post("/flags")
async def create_flag(
data: FlagCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Verify category belongs to kitchen
cat = await db.execute(
select(FoodFlagCategory).where(
FoodFlagCategory.id == data.category_id,
FoodFlagCategory.kitchen_id == user.kitchen_id,
)
)
if not cat.scalar_one_or_none():
raise HTTPException(404, "Category not found")
flag = FoodFlag(
category_id=data.category_id,
kitchen_id=user.kitchen_id,
name=data.name,
code=data.code,
icon=data.icon,
sort_order=data.sort_order,
)
db.add(flag)
await db.commit()
await db.refresh(flag)
return FlagResponse(
id=flag.id, name=flag.name, code=flag.code, icon=flag.icon,
sort_order=flag.sort_order, category_id=data.category_id,
)
@router.patch("/flags/{flag_id}")
async def update_flag(
flag_id: int,
data: FlagUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(FoodFlag).where(
FoodFlag.id == flag_id,
FoodFlag.kitchen_id == user.kitchen_id,
)
)
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(404, "Flag not found")
if data.name is not None:
flag.name = data.name
if data.code is not None:
flag.code = data.code
if data.icon is not None:
flag.icon = data.icon
if data.sort_order is not None:
flag.sort_order = data.sort_order
await db.commit()
return {"ok": True}
@router.delete("/flags/{flag_id}")
async def delete_flag(
flag_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(FoodFlag).where(
FoodFlag.id == flag_id,
FoodFlag.kitchen_id == user.kitchen_id,
)
)
flag = result.scalar_one_or_none()
if not flag:
raise HTTPException(404, "Flag not found")
# Explicitly clean up related records (belt + suspenders alongside CASCADE)
await db.execute(delete(IngredientFlag).where(IngredientFlag.food_flag_id == flag_id))
await db.execute(delete(RecipeFlag).where(RecipeFlag.food_flag_id == flag_id))
await db.execute(delete(RecipeFlagOverride).where(RecipeFlagOverride.food_flag_id == flag_id))
await db.execute(delete(LineItemFlag).where(LineItemFlag.food_flag_id == flag_id))
try:
await db.delete(flag)
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"Failed to delete flag {flag_id}: {e}")
raise HTTPException(500, f"Failed to delete flag: {str(e)}")
return {"ok": True}
# ── Line Item Flags + Latching ───────────────────────────────────────────────
@router.get("/line-items/{line_item_id}/flags")
async def get_line_item_flags(
line_item_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(LineItemFlag)
.options(selectinload(LineItemFlag.food_flag).selectinload(FoodFlag.category))
.where(LineItemFlag.line_item_id == line_item_id)
)
flags = result.scalars().all()
return [
LineItemFlagResponse(
id=f.id,
food_flag_id=f.food_flag_id,
flag_name=f.food_flag.name if f.food_flag else "",
flag_code=f.food_flag.code if f.food_flag else None,
category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "",
)
for f in flags
]
@router.put("/line-items/{line_item_id}/flags")
async def set_line_item_flags(
line_item_id: int,
data: LineItemFlagSet,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Set flags on a line item (full replacement). Triggers latching to mapped ingredient."""
# Get the line item to check ingredient_id
li_result = await db.execute(select(LineItem).where(LineItem.id == line_item_id))
li = li_result.scalar_one_or_none()
if not li:
raise HTTPException(404, "Line item not found")
# Delete existing line item flags
await db.execute(delete(LineItemFlag).where(LineItemFlag.line_item_id == line_item_id))
# Add new flags
for flag_id in data.food_flag_ids:
db.add(LineItemFlag(
line_item_id=line_item_id,
food_flag_id=flag_id,
flagged_by=user.id,
))
# Latching: if line item is mapped to an ingredient, auto-create ingredient_flag
if li.ingredient_id:
existing = await db.execute(
select(IngredientFlag).where(
IngredientFlag.ingredient_id == li.ingredient_id,
IngredientFlag.food_flag_id == flag_id,
)
)
if not existing.scalar_one_or_none():
db.add(IngredientFlag(
ingredient_id=li.ingredient_id,
food_flag_id=flag_id,
flagged_by=user.id,
source="latched",
))
await db.commit()
return {"ok": True}
# ── Recipe Flag Propagation ──────────────────────────────────────────────────
async def _collect_recipe_ingredient_ids(recipe_id: int, db: AsyncSession, depth: int = 0) -> list[int]:
"""Recursively collect all ingredient IDs used in a recipe (including sub-recipes)."""
if depth > 5:
return []
ingredient_ids = []
# Direct ingredients
ri_result = await db.execute(
select(RecipeIngredient.ingredient_id).where(RecipeIngredient.recipe_id == recipe_id)
)
ingredient_ids.extend([r[0] for r in ri_result.fetchall()])
# Sub-recipe ingredients (recursive)
sr_result = await db.execute(
select(RecipeSubRecipe.child_recipe_id).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
)
for (child_id,) in sr_result.fetchall():
child_ids = await _collect_recipe_ingredient_ids(child_id, db, depth + 1)
ingredient_ids.extend(child_ids)
return ingredient_ids
async def compute_recipe_flags(recipe_id: int, kitchen_id: int, db: AsyncSession) -> list[RecipeFlagState]:
"""Compute the full flag state for a recipe using ingredient_flags as canonical source."""
# Get all ingredient IDs (including sub-recipes)
all_ingredient_ids = await _collect_recipe_ingredient_ids(recipe_id, db)
if not all_ingredient_ids:
# Check for manual recipe flags only
manual_result = await db.execute(
select(RecipeFlag)
.options(selectinload(RecipeFlag.food_flag).selectinload(FoodFlag.category))
.where(RecipeFlag.recipe_id == recipe_id)
)
manual_flags = manual_result.scalars().all()
return [
RecipeFlagState(
food_flag_id=rf.food_flag_id,
flag_name=rf.food_flag.name if rf.food_flag else "",
flag_code=rf.food_flag.code if rf.food_flag else None,
flag_icon=rf.food_flag.icon if rf.food_flag else None,
category_id=rf.food_flag.category_id if rf.food_flag else 0,
category_name=rf.food_flag.category.name if rf.food_flag and rf.food_flag.category else "",
propagation_type=rf.food_flag.category.propagation_type if rf.food_flag and rf.food_flag.category else "contains",
source_type=rf.source_type,
is_active=rf.is_active,
excludable_on_request=rf.excludable_on_request,
)
for rf in manual_flags
]
unique_ingredient_ids = list(set(all_ingredient_ids))
# Get all ingredient flags for these ingredients
if_result = await db.execute(
select(IngredientFlag)
.options(selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category))
.where(IngredientFlag.ingredient_id.in_(unique_ingredient_ids))
)
ingredient_flags = if_result.scalars().all()
# Get all food flag categories for this kitchen
cat_result = await db.execute(
select(FoodFlagCategory)
.options(selectinload(FoodFlagCategory.flags))
.where(FoodFlagCategory.kitchen_id == kitchen_id)
)
categories = cat_result.scalars().all()
# Build ingredient -> flags mapping
ing_flag_map: dict[int, set[int]] = {}
for ifl in ingredient_flags:
ing_flag_map.setdefault(ifl.ingredient_id, set()).add(ifl.food_flag_id)
# Build flag_id -> ingredient_names mapping (for source tracing)
flag_source_names: dict[int, list[str]] = {}
# Load ingredient names
ing_names_result = await db.execute(
select(Ingredient.id, Ingredient.name).where(Ingredient.id.in_(unique_ingredient_ids))
)
ing_names = {r[0]: r[1] for r in ing_names_result.fetchall()}
for ifl in ingredient_flags:
flag_source_names.setdefault(ifl.food_flag_id, []).append(ing_names.get(ifl.ingredient_id, "?"))
# Compute propagated flags
computed_flags: dict[int, RecipeFlagState] = {}
for cat in categories:
if cat.propagation_type == "contains":
# Union: recipe has flag if ANY ingredient has it
for flag in cat.flags:
for ing_id in unique_ingredient_ids:
if flag.id in ing_flag_map.get(ing_id, set()):
computed_flags[flag.id] = RecipeFlagState(
food_flag_id=flag.id,
flag_name=flag.name,
flag_code=flag.code,
flag_icon=flag.icon,
category_id=cat.id,
category_name=cat.name,
propagation_type="contains",
source_type="auto",
is_active=True,
source_ingredients=flag_source_names.get(flag.id, []),
)
break
elif cat.propagation_type == "suitable_for":
# Intersection: recipe has flag only if ALL ingredients have it
for flag in cat.flags:
all_have = True
for ing_id in unique_ingredient_ids:
ing_flags = ing_flag_map.get(ing_id, set())
# Check if ingredient has ANY flags in this category (if not, it's unassessed)
cat_flag_ids = {f.id for f in cat.flags}
has_any_in_cat = bool(ing_flags & cat_flag_ids)
if not has_any_in_cat or flag.id not in ing_flags:
all_have = False
break
if all_have:
computed_flags[flag.id] = RecipeFlagState(
food_flag_id=flag.id,
flag_name=flag.name,
flag_code=flag.code,
flag_icon=flag.icon,
category_id=cat.id,
category_name=cat.name,
propagation_type="suitable_for",
source_type="auto",
is_active=True,
source_ingredients=[ing_names.get(i, "?") for i in unique_ingredient_ids],
)
# Merge with manual recipe flags and apply overrides
rf_result = await db.execute(
select(RecipeFlag)
.options(selectinload(RecipeFlag.food_flag).selectinload(FoodFlag.category))
.where(RecipeFlag.recipe_id == recipe_id)
)
recipe_flags = rf_result.scalars().all()
for rf in recipe_flags:
if rf.source_type == "manual" and rf.food_flag_id not in computed_flags:
computed_flags[rf.food_flag_id] = RecipeFlagState(
food_flag_id=rf.food_flag_id,
flag_name=rf.food_flag.name if rf.food_flag else "",
flag_code=rf.food_flag.code if rf.food_flag else None,
flag_icon=rf.food_flag.icon if rf.food_flag else None,
category_id=rf.food_flag.category_id if rf.food_flag else 0,
category_name=rf.food_flag.category.name if rf.food_flag and rf.food_flag.category else "",
propagation_type=rf.food_flag.category.propagation_type if rf.food_flag and rf.food_flag.category else "contains",
source_type="manual",
is_active=rf.is_active,
excludable_on_request=rf.excludable_on_request,
)
elif rf.food_flag_id in computed_flags:
# Apply overrides from recipe_flags to computed flags
computed_flags[rf.food_flag_id].is_active = rf.is_active
computed_flags[rf.food_flag_id].excludable_on_request = rf.excludable_on_request
if rf.source_type == "manual":
computed_flags[rf.food_flag_id].source_type = "manual"
return list(computed_flags.values())
# ── Recipe Flag endpoints ────────────────────────────────────────────────────
@router.get("/recipes/{recipe_id}/flags")
async def get_recipe_flags(
recipe_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Full flag state with source tracing and unassessed ingredient list."""
# Verify recipe
r = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not r.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)
# Find unassessed ingredients — per required category evaluation
all_ing_ids = await _collect_recipe_ingredient_ids(recipe_id, db)
unique_ids = list(set(all_ing_ids))
# Get required categories with their flag IDs
req_cat_result = await db.execute(
select(FoodFlagCategory.id, FoodFlagCategory.name).where(
FoodFlagCategory.kitchen_id == user.kitchen_id,
FoodFlagCategory.required == True,
)
)
required_cats = req_cat_result.all()
# Build map: category_id → set of flag_ids
cat_flag_map: dict[int, set[int]] = {}
for cat_id, _ in required_cats:
rf_result = await db.execute(
select(FoodFlag.id).where(FoodFlag.category_id == cat_id)
)
cat_flag_map[cat_id] = set(rf_result.scalars().all())
unassessed = []
if unique_ids and required_cats:
for ing_id in unique_ids:
ing_result = await db.execute(
select(Ingredient.name).where(Ingredient.id == ing_id)
)
name = ing_result.scalar()
if not name:
continue
# Get "None" entries for this ingredient
none_result = await db.execute(
select(IngredientFlagNone.category_id).where(
IngredientFlagNone.ingredient_id == ing_id,
)
)
none_cat_ids = set(none_result.scalars().all())
# Check each required category separately
for cat_id, cat_name in required_cats:
if cat_id in none_cat_ids:
continue # "None" selected for this category
flag_ids = cat_flag_map.get(cat_id, set())
if not flag_ids:
continue
flag_count = await db.execute(
select(func.count(IngredientFlag.id)).where(
IngredientFlag.ingredient_id == ing_id,
IngredientFlag.food_flag_id.in_(flag_ids),
)
)
if flag_count.scalar() == 0:
unassessed.append({"id": ing_id, "name": name, "category": cat_name})
break # One missing category is enough to flag the ingredient
# Find ingredients with open (undismissed, unapplied) allergen suggestions
open_suggestion_ings = []
if unique_ids:
# Load allergen keywords once
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
all_keywords = kw_result.scalars().all()
if all_keywords:
for ing_id in unique_ids:
# Get ingredient name + product_ingredients
ing_result = await db.execute(
select(Ingredient.name, Ingredient.product_ingredients).where(Ingredient.id == ing_id)
)
ing_row = ing_result.first()
if not ing_row:
continue
# Match keywords against name + product ingredients
texts_to_check = [ing_row.name or ""]
if ing_row.product_ingredients:
texts_to_check.append(ing_row.product_ingredients)
combined_text = " ".join(texts_to_check)
keyword_matches = match_allergen_keywords(combined_text, all_keywords)
if not keyword_matches:
continue
matched_flag_ids = set(m["flag_id"] for m in keyword_matches)
# Subtract active flags
active_result = await db.execute(
select(IngredientFlag.food_flag_id).where(
IngredientFlag.ingredient_id == ing_id,
)
)
active_ids = set(active_result.scalars().all())
matched_flag_ids -= active_ids
# Subtract dismissed flags
dismissed_result = await db.execute(
select(IngredientFlagDismissal.food_flag_id).where(
IngredientFlagDismissal.ingredient_id == ing_id,
)
)
dismissed_ids = set(dismissed_result.scalars().all())
matched_flag_ids -= dismissed_ids
if matched_flag_ids:
open_suggestion_ings.append({
"ingredient_id": ing_id,
"ingredient_name": ing_row.name,
"suggestion_count": len(matched_flag_ids),
})
# ── Recipe text keyword scanning ──────────────────────────────────
recipe_text_suggestions = []
# Load recipe details for text scanning
recipe_result = await db.execute(
select(Recipe.name, Recipe.description, Recipe.notes).where(Recipe.id == recipe_id)
)
recipe_row = recipe_result.first()
# Reuse all_keywords if already loaded above, otherwise load now
if not unique_ids or not all_keywords:
kw_result2 = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
all_keywords = kw_result2.scalars().all()
if recipe_row and all_keywords:
# Collect text sources: (text_value, source_label)
text_sources: list[tuple[str, str]] = []
if recipe_row.name:
text_sources.append((recipe_row.name, "recipe name"))
if recipe_row.description:
text_sources.append((recipe_row.description, "description"))
if recipe_row.notes:
text_sources.append((recipe_row.notes, "notes"))
# Also scan ingredient notes
ing_notes_result = await db.execute(
select(RecipeIngredient.notes, Ingredient.name)
.join(Ingredient, Ingredient.id == RecipeIngredient.ingredient_id)
.where(
RecipeIngredient.recipe_id == recipe_id,
RecipeIngredient.notes.isnot(None),
RecipeIngredient.notes != "",
)
)
for note_text, ing_name in ing_notes_result.all():
text_sources.append((note_text, f"ingredient note: {ing_name}"))
# Match keywords against each text source
text_flag_matches: dict[int, dict] = {}
for src_text, src_label in text_sources:
matches = match_allergen_keywords(src_text, all_keywords)
for m in matches:
fid = m["flag_id"]
if fid not in text_flag_matches:
text_flag_matches[fid] = {
"flag_id": fid,
"flag_name": m["flag_name"],
"flag_code": m.get("flag_code"),
"category_name": m["category_name"],
"matched_keywords": [],
"sources": [],
}
for kw in m["matched_keywords"]:
entry = f"{kw} ({src_label})"
if entry not in text_flag_matches[fid]["matched_keywords"]:
text_flag_matches[fid]["matched_keywords"].append(entry)
if src_label not in text_flag_matches[fid]["sources"]:
text_flag_matches[fid]["sources"].append(src_label)
if text_flag_matches:
# Subtract flags already in computed recipe flags
computed_flag_ids = set(f.food_flag_id for f in flags)
for fid in list(text_flag_matches.keys()):
if fid in computed_flag_ids:
del text_flag_matches[fid]
# Subtract dismissed flags
dismissed_result = await db.execute(
select(RecipeTextFlagDismissal.food_flag_id).where(
RecipeTextFlagDismissal.recipe_id == recipe_id,
)
)
dismissed_ids = set(dismissed_result.scalars().all())
for fid in list(text_flag_matches.keys()):
if fid in dismissed_ids:
del text_flag_matches[fid]
recipe_text_suggestions = list(text_flag_matches.values())
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
# Optionally run LLM analysis on recipe text for contextual allergen detection
llm_recipe_suggestions = []
try:
from services.llm_service import analyse_product_label
# Concatenate all text sources for LLM
all_text_parts = []
if recipe_row:
if recipe_row.name:
all_text_parts.append(f"Recipe: {recipe_row.name}")
if recipe_row.description:
all_text_parts.append(f"Description: {recipe_row.description}")
if recipe_row.notes:
all_text_parts.append(f"Notes: {recipe_row.notes}")
combined_text = "\n".join(all_text_parts)
if combined_text and len(combined_text.strip()) >= 10:
# Build flag categories for LLM
cat_result_llm = await db.execute(
select(FoodFlagCategory)
.options(selectinload(FoodFlagCategory.flags))
.where(FoodFlagCategory.kitchen_id == user.kitchen_id)
)
llm_categories = cat_result_llm.scalars().all()
flag_categories_for_llm = [
{
"category_name": c.name,
"propagation_type": c.propagation_type,
"flags": [{"id": f.id, "name": f.name, "code": f.code} for f in c.flags],
}
for c in llm_categories
]
llm_result = await analyse_product_label(db, user.kitchen_id, combined_text, flag_categories_for_llm)
if llm_result["status"] in ("success", "cached") and llm_result.get("suggestions"):
# Merge LLM suggestions — only add flags not already in keyword results or computed flags
computed_flag_ids = set(f.food_flag_id for f in flags)
keyword_flag_ids = set(s["flag_id"] for s in recipe_text_suggestions)
dismissed_result2 = await db.execute(
select(RecipeTextFlagDismissal.food_flag_id).where(
RecipeTextFlagDismissal.recipe_id == recipe_id,
)
)
dismissed_ids2 = set(dismissed_result2.scalars().all())
for s in llm_result["suggestions"]:
fid = s["flag_id"]
if fid not in computed_flag_ids and fid not in keyword_flag_ids and fid not in dismissed_ids2:
llm_recipe_suggestions.append(s)
except Exception as e:
logger.warning(f"LLM recipe text scanning failed (non-fatal): {e}")
return {
"flags": [f.model_dump() for f in flags],
"unassessed_ingredients": unassessed,
"open_suggestion_ingredients": open_suggestion_ings,
"recipe_text_suggestions": recipe_text_suggestions,
"llm_recipe_suggestions": llm_recipe_suggestions, # LLM FEATURE
}
@router.post("/recipes/{recipe_id}/flags/{flag_id}/deactivate")
async def deactivate_recipe_flag(
recipe_id: int,
flag_id: int,
data: OverrideRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Override a flag off (requires note, creates audit log)."""
# Upsert recipe_flag
rf_result = await db.execute(
select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id)
)
rf = rf_result.scalar_one_or_none()
if rf:
rf.is_active = False
else:
rf = RecipeFlag(recipe_id=recipe_id, food_flag_id=flag_id, source_type="auto", is_active=False)
db.add(rf)
# Audit log
db.add(RecipeFlagOverride(
recipe_id=recipe_id, food_flag_id=flag_id,
action="deactivated", note=data.note, user_id=user.id,
))
await db.commit()
return {"ok": True}
@router.post("/recipes/{recipe_id}/flags/{flag_id}/reactivate")
async def reactivate_recipe_flag(
recipe_id: int,
flag_id: int,
data: OverrideRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rf_result = await db.execute(
select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id)
)
rf = rf_result.scalar_one_or_none()
if rf:
rf.is_active = True
db.add(RecipeFlagOverride(
recipe_id=recipe_id, food_flag_id=flag_id,
action="reactivated", note=data.note, user_id=user.id,
))
await db.commit()
return {"ok": True}
@router.patch("/recipes/{recipe_id}/flags/{flag_id}")
async def toggle_excludable(
recipe_id: int,
flag_id: int,
data: ExcludableToggle,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Toggle excludable_on_request for a dish recipe flag."""
rf_result = await db.execute(
select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id)
)
rf = rf_result.scalar_one_or_none()
if rf:
new_state = not rf.excludable_on_request
rf.excludable_on_request = new_state
action = "set_excludable" if new_state else "unset_excludable"
else:
rf = RecipeFlag(
recipe_id=recipe_id, food_flag_id=flag_id,
source_type="auto", is_active=True, excludable_on_request=True,
)
db.add(rf)
action = "set_excludable"
db.add(RecipeFlagOverride(
recipe_id=recipe_id, food_flag_id=flag_id,
action=action, note=data.note, user_id=user.id,
))
await db.commit()
return {"ok": True}
@router.post("/recipes/{recipe_id}/flags/manual")
async def add_manual_flag(
recipe_id: int,
data: ManualFlagAdd,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Manually add a flag to a recipe."""
existing = await db.execute(
select(RecipeFlag).where(
RecipeFlag.recipe_id == recipe_id,
RecipeFlag.food_flag_id == data.food_flag_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(409, "Flag already exists on this recipe")
db.add(RecipeFlag(
recipe_id=recipe_id, food_flag_id=data.food_flag_id,
source_type="manual", is_active=True,
))
await db.commit()
return {"ok": True}
@router.get("/recipes/{recipe_id}/flags/audit-log")
async def get_flag_audit_log(
recipe_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RecipeFlagOverride)
.options(
selectinload(RecipeFlagOverride.food_flag),
selectinload(RecipeFlagOverride.user),
)
.where(RecipeFlagOverride.recipe_id == recipe_id)
.order_by(RecipeFlagOverride.created_at.desc())
)
overrides = result.scalars().all()
return [
RecipeFlagOverrideLog(
id=o.id,
food_flag_id=o.food_flag_id,
flag_name=o.food_flag.name if o.food_flag else "",
action=o.action,
note=o.note,
username=o.user.username if o.user else "",
created_at=str(o.created_at) if o.created_at else "",
)
for o in overrides
]
@router.get("/recipes/{recipe_id}/flags/matrix")
async def get_flag_matrix(
recipe_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Full ingredient × flag matrix data for the flag breakdown table."""
# Verify recipe
r_result = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
recipe = r_result.scalar_one_or_none()
if not recipe:
raise HTTPException(404, "Recipe not found")
# Get all flag categories and flags
cats = await db.execute(
select(FoodFlagCategory)
.options(selectinload(FoodFlagCategory.flags))
.where(FoodFlagCategory.kitchen_id == user.kitchen_id)
.order_by(FoodFlagCategory.sort_order)
)
categories = cats.scalars().all()
all_flags = []
required_cat_ids = set()
for cat in categories:
if cat.required:
required_cat_ids.add(cat.id)
for f in sorted(cat.flags, key=lambda x: x.sort_order):
all_flags.append({
"id": f.id, "name": f.name, "code": f.code,
"category_id": cat.id, "category_name": cat.name,
"propagation_type": cat.propagation_type,
"required": cat.required,
})
matrix_rows = []
# Direct recipe ingredients
ri_result = await db.execute(
select(RecipeIngredient)
.options(selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.flags))
.where(RecipeIngredient.recipe_id == recipe_id)
.order_by(RecipeIngredient.sort_order)
)
direct_ris = ri_result.scalars().all()
# Sub-recipe ingredients
sr_result = await db.execute(
select(RecipeSubRecipe)
.options(selectinload(RecipeSubRecipe.child_recipe))
.where(RecipeSubRecipe.parent_recipe_id == recipe_id)
.order_by(RecipeSubRecipe.sort_order)
)
sub_recipes = sr_result.scalars().all()
# Fetch child recipe ingredients for each sub-recipe
sub_recipe_ingredients = {} # child_id -> (child_recipe, [RecipeIngredient...])
for sr in sub_recipes:
child = sr.child_recipe
if not child:
continue
cri_result = await db.execute(
select(RecipeIngredient)
.options(selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.flags))
.where(RecipeIngredient.recipe_id == child.id)
.order_by(RecipeIngredient.sort_order)
)
sub_recipe_ingredients[child.id] = (child, cri_result.scalars().all())
# Collect all ingredient IDs from direct + sub-recipe ingredients
all_ingredient_ids = set()
for ri in direct_ris:
if ri.ingredient:
all_ingredient_ids.add(ri.ingredient.id)
for child_id, (child, cris) in sub_recipe_ingredients.items():
for cri in cris:
if cri.ingredient:
all_ingredient_ids.add(cri.ingredient.id)
# Batch-fetch all "None apply" records so they count as assessed
ingredient_nones = {}
if all_ingredient_ids:
none_result = await db.execute(
select(IngredientFlagNone.ingredient_id, IngredientFlagNone.category_id)
.where(IngredientFlagNone.ingredient_id.in_(list(all_ingredient_ids)))
)
for ing_id, cat_id in none_result.all():
if ing_id not in ingredient_nones:
ingredient_nones[ing_id] = set()
ingredient_nones[ing_id].add(cat_id)
# Compute open suggestion flag IDs per ingredient
ingredient_open_suggestions: dict[int, set[int]] = {} # ing_id -> set of flag_ids with open suggestions
if all_ingredient_ids:
# Load allergen keywords
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
all_keywords = kw_result.scalars().all()
if all_keywords:
# Batch-fetch dismissed flag IDs per ingredient
ingredient_dismissed: dict[int, set[int]] = {}
dismiss_result = await db.execute(
select(IngredientFlagDismissal.ingredient_id, IngredientFlagDismissal.food_flag_id)
.where(IngredientFlagDismissal.ingredient_id.in_(list(all_ingredient_ids)))
)
for ing_id, flag_id in dismiss_result.all():
if ing_id not in ingredient_dismissed:
ingredient_dismissed[ing_id] = set()
ingredient_dismissed[ing_id].add(flag_id)
# Batch-fetch ingredient names and product_ingredients
ing_data_result = await db.execute(
select(Ingredient.id, Ingredient.name, Ingredient.product_ingredients)
.where(Ingredient.id.in_(list(all_ingredient_ids)))
)
for ing_id, ing_name, prod_ing in ing_data_result.all():
texts = [ing_name or ""]
if prod_ing:
texts.append(prod_ing)
combined = " ".join(texts)
matches = match_allergen_keywords(combined, all_keywords)
if matches:
matched_flag_ids = set(m["flag_id"] for m in matches)
# Subtract active flags (from the ingredient's loaded flags)
# We'll do this per-row below since we already have ing_flag_ids there
# For now, subtract dismissed
matched_flag_ids -= ingredient_dismissed.get(ing_id, set())
if matched_flag_ids:
ingredient_open_suggestions[ing_id] = matched_flag_ids
# Build matrix rows for direct ingredients
for ri in direct_ris:
ing = ri.ingredient
if not ing:
continue
ing_flag_ids = {f.food_flag_id for f in (ing.flags or [])}
# Check which categories this ingredient has ANY flags in
assessed_cats = set()
for f in (ing.flags or []):
for cat in categories:
if f.food_flag_id in {cf.id for cf in cat.flags}:
assessed_cats.add(cat.id)
# Also count "None apply" categories as assessed
assessed_cats |= ingredient_nones.get(ing.id, set())
none_cats = ingredient_nones.get(ing.id, set())
open_sugg = ingredient_open_suggestions.get(ing.id, set()) - ing_flag_ids
flags_map = {}
for flag_info in all_flags:
fid = flag_info["id"]
cat_id = flag_info["category_id"]
# Non-required categories: never show as unassessed
is_unassessed = cat_id not in assessed_cats and cat_id in required_cat_ids
flags_map[fid] = MatrixCell(
has_flag=fid in ing_flag_ids,
is_unassessed=is_unassessed,
is_none=cat_id in none_cats,
has_open_suggestion=fid in open_sugg,
).model_dump()
matrix_rows.append(MatrixIngredient(
ingredient_id=ing.id, ingredient_name=ing.name,
flags=flags_map,
).model_dump())
# Build matrix rows for sub-recipe ingredients
for sr in sub_recipes:
child = sr.child_recipe
if not child or child.id not in sub_recipe_ingredients:
continue
_, cris = sub_recipe_ingredients[child.id]
for cri in cris:
cing = cri.ingredient
if not cing:
continue
ing_flag_ids = {f.food_flag_id for f in (cing.flags or [])}
assessed_cats = set()
for f in (cing.flags or []):
for cat in categories:
if f.food_flag_id in {cf.id for cf in cat.flags}:
assessed_cats.add(cat.id)
# Also count "None apply" categories as assessed
assessed_cats |= ingredient_nones.get(cing.id, set())
none_cats = ingredient_nones.get(cing.id, set())
open_sugg = ingredient_open_suggestions.get(cing.id, set()) - ing_flag_ids
flags_map = {}
for flag_info in all_flags:
fid = flag_info["id"]
cat_id = flag_info["category_id"]
is_unassessed = cat_id not in assessed_cats and cat_id in required_cat_ids
flags_map[fid] = MatrixCell(
has_flag=fid in ing_flag_ids,
is_unassessed=is_unassessed,
is_none=cat_id in none_cats,
has_open_suggestion=fid in open_sugg,
).model_dump()
matrix_rows.append(MatrixIngredient(
ingredient_id=cing.id, ingredient_name=cing.name,
is_sub_recipe=True, sub_recipe_name=child.name,
flags=flags_map,
).model_dump())
return {"flags": all_flags, "ingredients": matrix_rows}
class MatrixBulkItem(BaseModel):
ingredient_id: int
food_flag_id: int
has_flag: bool
class MatrixBulkUpdate(BaseModel):
updates: list[MatrixBulkItem]
@router.put("/recipes/{recipe_id}/flags/matrix")
async def update_flag_matrix(
recipe_id: int,
data: MatrixBulkUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Bulk update ingredient flags from the recipe flag matrix view."""
# Verify recipe belongs to kitchen
r_result = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not r_result.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
updated = 0
for item in data.updates:
# Verify ingredient belongs to this kitchen
ing_result = await db.execute(
select(Ingredient).where(
Ingredient.id == item.ingredient_id,
Ingredient.kitchen_id == user.kitchen_id,
)
)
ingredient = ing_result.scalar_one_or_none()
if not ingredient:
continue
# Verify flag belongs to this kitchen
flag_result = await db.execute(
select(FoodFlag).where(
FoodFlag.id == item.food_flag_id,
FoodFlag.kitchen_id == user.kitchen_id,
)
)
if not flag_result.scalar_one_or_none():
continue
# Check if flag assignment exists
existing = await db.execute(
select(IngredientFlag).where(
IngredientFlag.ingredient_id == item.ingredient_id,
IngredientFlag.food_flag_id == item.food_flag_id,
)
)
flag_row = existing.scalar_one_or_none()
if item.has_flag and not flag_row:
# Add flag — also remove any "None apply" for this flag's category
flag_obj = flag_result.scalar_one_or_none() if not flag_result else None
# Re-fetch to get category_id
flag_detail = await db.execute(
select(FoodFlag).where(FoodFlag.id == item.food_flag_id)
)
flag_detail_obj = flag_detail.scalar_one_or_none()
if flag_detail_obj:
await db.execute(
delete(IngredientFlagNone).where(
IngredientFlagNone.ingredient_id == item.ingredient_id,
IngredientFlagNone.category_id == flag_detail_obj.category_id,
)
)
db.add(IngredientFlag(
ingredient_id=item.ingredient_id,
food_flag_id=item.food_flag_id,
flagged_by=user.id,
source="manual",
))
updated += 1
elif not item.has_flag and flag_row:
# Remove flag (only manual ones; latched flags stay)
if flag_row.source == "manual":
await db.delete(flag_row)
updated += 1
if updated > 0:
# Bump recipe updated_at so menu staleness detection picks up flag changes
r2 = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe_obj = r2.scalar_one_or_none()
if recipe_obj:
recipe_obj.updated_at = datetime.utcnow()
await db.commit()
return {"ok": True, "updated": updated}
class MatrixNoneToggle(BaseModel):
ingredient_id: int
category_id: int
@router.post("/recipes/{recipe_id}/flags/matrix/none")
async def toggle_matrix_none(
recipe_id: int,
data: MatrixNoneToggle,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Toggle 'None apply' for a category on an ingredient, from the recipe matrix."""
# Verify recipe belongs to kitchen
r_result = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not r_result.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
# Check if already set
existing = await db.execute(
select(IngredientFlagNone).where(
IngredientFlagNone.ingredient_id == data.ingredient_id,
IngredientFlagNone.category_id == data.category_id,
)
)
none_row = existing.scalar_one_or_none()
# Helper to bump recipe updated_at for staleness detection
async def _bump_recipe():
r2 = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
robj = r2.scalar_one_or_none()
if robj:
robj.updated_at = datetime.utcnow()
if none_row:
# Toggle OFF
await db.delete(none_row)
await _bump_recipe()
await db.commit()
return {"ok": True, "is_none": False}
else:
# Toggle ON — remove any flags in this category first
cat_flag_ids = await db.execute(
select(FoodFlag.id).where(
FoodFlag.category_id == data.category_id,
FoodFlag.kitchen_id == user.kitchen_id,
)
)
flag_ids = [r for r in cat_flag_ids.scalars().all()]
if flag_ids:
await db.execute(
delete(IngredientFlag).where(
IngredientFlag.ingredient_id == data.ingredient_id,
IngredientFlag.food_flag_id.in_(flag_ids),
)
)
db.add(IngredientFlagNone(
ingredient_id=data.ingredient_id,
category_id=data.category_id,
))
await _bump_recipe()
await db.commit()
return {"ok": True, "is_none": True}
# ── Recipe text flag dismissals ──────────────────────────────────────────────
class RecipeTextDismissalCreate(BaseModel):
food_flag_id: int
dismissed_by_name: str
reason: Optional[str] = None
matched_keyword: Optional[str] = None
class RecipeTextDismissalResponse(BaseModel):
id: int
recipe_id: int
food_flag_id: int
dismissed_by_name: str
reason: Optional[str] = None
matched_keyword: Optional[str] = None
created_at: str = ""
class Config:
from_attributes = True
@router.get("/recipes/{recipe_id}/text-dismissals")
async def get_recipe_text_dismissals(
recipe_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get all dismissed recipe text allergen suggestions."""
r = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not r.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
result = await db.execute(
select(RecipeTextFlagDismissal)
.where(RecipeTextFlagDismissal.recipe_id == recipe_id)
.order_by(RecipeTextFlagDismissal.created_at.desc())
)
dismissals = result.scalars().all()
return [
RecipeTextDismissalResponse(
id=d.id,
recipe_id=d.recipe_id,
food_flag_id=d.food_flag_id,
dismissed_by_name=d.dismissed_by_name,
reason=d.reason,
matched_keyword=d.matched_keyword,
created_at=str(d.created_at) if d.created_at else "",
)
for d in dismissals
]
@router.post("/recipes/{recipe_id}/text-dismissals")
async def create_recipe_text_dismissal(
recipe_id: int,
data: RecipeTextDismissalCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Dismiss a recipe text allergen suggestion (upsert)."""
r = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not r.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
existing = await db.execute(
select(RecipeTextFlagDismissal).where(
RecipeTextFlagDismissal.recipe_id == recipe_id,
RecipeTextFlagDismissal.food_flag_id == data.food_flag_id,
)
)
dismissal = existing.scalar_one_or_none()
if dismissal:
dismissal.dismissed_by_name = data.dismissed_by_name
dismissal.reason = data.reason
dismissal.matched_keyword = data.matched_keyword
else:
dismissal = RecipeTextFlagDismissal(
recipe_id=recipe_id,
food_flag_id=data.food_flag_id,
dismissed_by_name=data.dismissed_by_name,
reason=data.reason,
matched_keyword=data.matched_keyword,
)
db.add(dismissal)
await db.commit()
await db.refresh(dismissal)
return RecipeTextDismissalResponse(
id=dismissal.id,
recipe_id=dismissal.recipe_id,
food_flag_id=dismissal.food_flag_id,
dismissed_by_name=dismissal.dismissed_by_name,
reason=dismissal.reason,
matched_keyword=dismissal.matched_keyword,
created_at=str(dismissal.created_at) if dismissal.created_at else "",
)
@router.delete("/recipes/{recipe_id}/text-dismissals/{dismissal_id}")
async def delete_recipe_text_dismissal(
recipe_id: int,
dismissal_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Undo a recipe text dismissal (re-enables the suggestion)."""
r = await db.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not r.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
result = await db.execute(
select(RecipeTextFlagDismissal).where(
RecipeTextFlagDismissal.id == dismissal_id,
RecipeTextFlagDismissal.recipe_id == recipe_id,
)
)
dismissal = result.scalar_one_or_none()
if not dismissal:
raise HTTPException(404, "Dismissal not found")
await db.delete(dismissal)
await db.commit()
return {"ok": True}
# ── Shared allergen keyword matching ─────────────────────────────────────────
def match_allergen_keywords(text: str, keywords: list) -> list[dict]:
"""Match text against allergen keywords using word boundary matching.
Allows optional plural suffixes (s, es, 's) so 'almond' matches 'almonds' etc."""
text_lower = text.lower()
matches: dict[int, dict] = {}
for kw in keywords:
# Word boundary + optional plural/possessive suffix to catch almonds, walnuts, etc.
pattern = r'\b' + re.escape(kw.keyword) + r"(?:'?e?s)?\b"
if re.search(pattern, text_lower):
fid = kw.food_flag_id
if fid not in matches:
flag = kw.food_flag
matches[fid] = {
"flag_id": fid,
"flag_name": flag.name if flag else "",
"flag_code": flag.code if flag else None,
"category_name": flag.category.name if flag and flag.category else "",
"matched_keywords": [],
}
matches[fid]["matched_keywords"].append(kw.keyword)
return list(matches.values())
# ── Allergen keyword suggestion ──────────────────────────────────────────────
@router.get("/suggest")
async def suggest_allergens(
name: str = Query("", description="Ingredient name to check"),
text: str = Query("", description="Product ingredients text to check"),
line_item: str = Query("", description="Line item description to check"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Suggest allergen flags based on ingredient name, line item description, and/or product ingredients text."""
if not any(len(s.strip()) >= 2 for s in [name, text, line_item]):
return []
result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
keywords = result.scalars().all()
# Match each source separately and annotate keywords with their origin
sources = [
(name, "name"),
(line_item, "line item"),
(text, "ingredients"),
]
merged: dict[int, dict] = {}
for input_text, source_label in sources:
if not input_text or len(input_text.strip()) < 2:
continue
matches = match_allergen_keywords(input_text, keywords)
for m in matches:
fid = m["flag_id"]
if fid not in merged:
merged[fid] = {
"flag_id": m["flag_id"],
"flag_name": m["flag_name"],
"flag_code": m["flag_code"],
"category_name": m["category_name"],
"matched_keywords": [],
}
for kw in m["matched_keywords"]:
merged[fid]["matched_keywords"].append(f"{kw} ({source_label})")
return list(merged.values())
@router.get("/suggest/bulk")
async def suggest_allergens_bulk(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return allergen keyword suggestions for ALL ingredients in the kitchen.
Single DB query for keywords, then match against each ingredient's name + product_ingredients.
Returns: { ingredient_id: [ { flag_id, flag_name, flag_code, category_name, matched_keywords } ] }
"""
# Load all keywords once
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
keywords = kw_result.scalars().all()
if not keywords:
return {}
# Load all non-archived ingredients
ing_result = await db.execute(
select(Ingredient.id, Ingredient.name, Ingredient.product_ingredients)
.where(Ingredient.kitchen_id == user.kitchen_id, Ingredient.is_archived == False)
)
ingredients = ing_result.all()
result: dict[int, list[dict]] = {}
for ing_id, ing_name, product_ingredients in ingredients:
merged: dict[int, dict] = {}
for input_text, source_label in [(ing_name, "name"), (product_ingredients, "ingredients")]:
if not input_text or len(input_text.strip()) < 2:
continue
matches = match_allergen_keywords(input_text, keywords)
for m in matches:
fid = m["flag_id"]
if fid not in merged:
merged[fid] = {
"flag_id": m["flag_id"],
"flag_name": m["flag_name"],
"flag_code": m["flag_code"],
"category_name": m["category_name"],
"matched_keywords": [],
}
for kw in m["matched_keywords"]:
merged[fid]["matched_keywords"].append(f"{kw} ({source_label})")
if merged:
result[ing_id] = list(merged.values())
return result
# ── LLM Label Analysis ──────────────────────────────────────────────────────
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
class AnalyseLabelRequest(BaseModel):
ingredients_text: str | None = None
ingredient_id: int | None = None
@router.post("/analyse-label")
async def analyse_label(
body: AnalyseLabelRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Analyse product ingredient text or recipe text for allergens using LLM.
Returns suggestions in the same format as keyword matching, with added status and source fields.
Falls back to keyword matching if LLM is unavailable.
"""
# Get the text to analyse
text = body.ingredients_text
if not text and body.ingredient_id:
result = await db.execute(
select(Ingredient).where(
Ingredient.id == body.ingredient_id,
Ingredient.kitchen_id == user.kitchen_id,
)
)
ingredient = result.scalar_one_or_none()
if ingredient and ingredient.product_ingredients:
text = ingredient.product_ingredients
if not text or len(text.strip()) < 3:
return {"llm_status": "unavailable", "suggestions": [], "keyword_suggestions": []}
# Always run keyword matching as baseline
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
keywords = kw_result.scalars().all()
keyword_suggestions = match_allergen_keywords(text, keywords)
# Build flag categories for LLM
cat_result = await db.execute(
select(FoodFlagCategory)
.options(selectinload(FoodFlagCategory.flags))
.where(FoodFlagCategory.kitchen_id == user.kitchen_id)
)
categories = cat_result.scalars().all()
flag_categories = []
for cat in categories:
flag_categories.append({
"category_name": cat.name,
"propagation_type": cat.propagation_type,
"flags": [{"id": f.id, "name": f.name, "code": f.code} for f in cat.flags],
})
# Call LLM analysis
from services.llm_service import analyse_product_label
llm_result = await analyse_product_label(db, user.kitchen_id, text, flag_categories)
return {
"llm_status": llm_result["status"],
"suggestions": llm_result.get("suggestions") or [],
"keyword_suggestions": keyword_suggestions,
"error": llm_result.get("error"),
}
# ── Allergen keyword CRUD (Settings) ────────────────────────────────────────
class KeywordCreate(BaseModel):
food_flag_id: int
keyword: str
@router.get("/keywords")
async def list_keywords(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return all allergen keywords grouped by flag."""
result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
.order_by(AllergenKeyword.food_flag_id, AllergenKeyword.keyword)
)
keywords = result.scalars().all()
groups: dict[int, dict] = {}
for kw in keywords:
fid = kw.food_flag_id
if fid not in groups:
flag = kw.food_flag
groups[fid] = {
"flag_id": fid,
"flag_name": flag.name if flag else "",
"flag_code": flag.code if flag else None,
"category_name": flag.category.name if flag and flag.category else "",
"keywords": [],
}
groups[fid]["keywords"].append({
"id": kw.id,
"keyword": kw.keyword,
"is_default": kw.is_default,
})
return list(groups.values())
@router.post("/keywords")
async def add_keyword(
data: KeywordCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Add a custom allergen keyword."""
# Verify flag belongs to this kitchen
flag = await db.get(FoodFlag, data.food_flag_id)
if not flag or flag.kitchen_id != user.kitchen_id:
raise HTTPException(404, "Flag not found")
kw = AllergenKeyword(
kitchen_id=user.kitchen_id,
food_flag_id=data.food_flag_id,
keyword=data.keyword.lower().strip(),
is_default=False,
)
db.add(kw)
try:
await db.commit()
await db.refresh(kw)
except Exception:
await db.rollback()
raise HTTPException(409, "Keyword already exists for this flag")
return {"id": kw.id, "keyword": kw.keyword, "is_default": False}
@router.delete("/keywords/{keyword_id}")
async def delete_keyword(
keyword_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Remove an allergen keyword."""
kw = await db.get(AllergenKeyword, keyword_id)
if not kw or kw.kitchen_id != user.kitchen_id:
raise HTTPException(404, "Keyword not found")
await db.delete(kw)
await db.commit()
return {"ok": True}
@router.post("/keywords/reset-defaults")
async def reset_default_keywords(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete all default keywords and re-seed from built-in dictionary. Preserves user-added keywords."""
from migrations.add_allergen_keywords import ALLERGEN_KEYWORDS
from sqlalchemy import text as sql_text
# Delete existing defaults
await db.execute(
delete(AllergenKeyword).where(
AllergenKeyword.kitchen_id == user.kitchen_id,
AllergenKeyword.is_default == True,
)
)
await db.flush()
# Re-seed defaults
seeded = 0
for flag_name, keywords in ALLERGEN_KEYWORDS.items():
flag_result = await db.execute(
select(FoodFlag).where(
FoodFlag.kitchen_id == user.kitchen_id,
FoodFlag.name == flag_name,
)
)
flag = flag_result.scalar_one_or_none()
if not flag:
continue
for kw_str in keywords:
kw = AllergenKeyword(
kitchen_id=user.kitchen_id,
food_flag_id=flag.id,
keyword=kw_str.lower(),
is_default=True,
)
db.add(kw)
seeded += 1
try:
await db.commit()
except Exception:
await db.rollback()
raise HTTPException(500, "Failed to re-seed keywords")
return {"ok": True, "seeded": seeded}
# ── Label OCR scanning ──────────────────────────────────────────────────────
@router.post("/scan-label")
async def scan_label(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""OCR a product ingredient label image and suggest allergen flags.
For create mode (ingredient doesn't exist yet) — returns raw text + suggestions.
"""
# Validate file type
allowed = {"image/jpeg", "image/png", "image/webp", "image/heic"}
if file.content_type not in allowed:
raise HTTPException(400, f"Unsupported file type: {file.content_type}")
# Get Azure credentials
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == user.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
if not settings or not settings.azure_endpoint or not settings.azure_key:
raise HTTPException(400, "Azure Document Intelligence not configured. Set it up in Settings.")
# Read file content
image_bytes = await file.read()
# OCR with Azure prebuilt-read
try:
from azure.ai.formrecognizer import DocumentAnalysisClient
from azure.core.credentials import AzureKeyCredential
client = DocumentAnalysisClient(settings.azure_endpoint, AzureKeyCredential(settings.azure_key))
poller = client.begin_analyze_document("prebuilt-read", document=image_bytes)
result = poller.result()
raw_text = " ".join([line.content for page in result.pages for line in page.lines])
except Exception as e:
logger.error(f"Azure OCR failed: {e}")
raise HTTPException(500, f"OCR failed: {str(e)}")
# Match keywords
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
keywords = kw_result.scalars().all()
suggestions = match_allergen_keywords(raw_text, keywords)
return {
"raw_text": raw_text,
"suggested_flags": suggestions,
}
@router.post("/scan-label/{ingredient_id}")
async def scan_label_for_ingredient(
ingredient_id: int,
file: UploadFile = File(...),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""OCR a product ingredient label, save the image, and suggest allergen flags.
For edit mode (ingredient exists) — saves label image to disk.
"""
# Verify ingredient
ing = await db.get(Ingredient, ingredient_id)
if not ing or ing.kitchen_id != user.kitchen_id:
raise HTTPException(404, "Ingredient not found")
# Validate file type
allowed = {"image/jpeg", "image/png", "image/webp", "image/heic"}
if file.content_type not in allowed:
raise HTTPException(400, f"Unsupported file type: {file.content_type}")
# Get Azure credentials
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == user.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
if not settings or not settings.azure_endpoint or not settings.azure_key:
raise HTTPException(400, "Azure Document Intelligence not configured. Set it up in Settings.")
# Read file content
image_bytes = await file.read()
# Save label image
ext = file.filename.rsplit(".", 1)[-1] if file.filename and "." in file.filename else "jpg"
label_dir = f"/app/data/{user.kitchen_id}/labels"
os.makedirs(label_dir, exist_ok=True)
filename = f"{uuid.uuid4()}.{ext}"
filepath = os.path.join(label_dir, filename)
async with aiofiles.open(filepath, "wb") as f:
await f.write(image_bytes)
# Update ingredient
ing.label_image_path = filepath
await db.flush()
# OCR with Azure prebuilt-read
try:
from azure.ai.formrecognizer import DocumentAnalysisClient
from azure.core.credentials import AzureKeyCredential
client = DocumentAnalysisClient(settings.azure_endpoint, AzureKeyCredential(settings.azure_key))
poller = client.begin_analyze_document("prebuilt-read", document=image_bytes)
result = poller.result()
raw_text = " ".join([line.content for page in result.pages for line in page.lines])
except Exception as e:
logger.error(f"Azure OCR failed: {e}")
await db.commit() # Still save the image even if OCR fails
raise HTTPException(500, f"OCR failed: {str(e)}")
# Update product_ingredients
ing.product_ingredients = raw_text
await db.commit()
# Match keywords
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == user.kitchen_id)
)
keywords = kw_result.scalars().all()
suggestions = match_allergen_keywords(raw_text, keywords)
return {
"raw_text": raw_text,
"suggested_flags": suggestions,
"label_saved": True,
}
# ── Brakes product lookup ─────────────────────────────────────────────────
@router.get("/brakes-lookup")
async def brakes_lookup(
product_code: str = Query(..., description="Brakes product code"),
force: bool = Query(False, description="Bypass cache and re-fetch from website"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Look up a Brakes product by code, returning ingredients + allergen suggestions.
Uses a cache table to avoid repeated requests to brake.co.uk.
"""
import json
from datetime import datetime, timedelta
from services.brakes_scraper import fetch_brakes_product
clean_code = product_code.lstrip("$").strip()
if not clean_code:
raise HTTPException(400, "Product code required")
# Check cache
cache_result = await db.execute(
select(BrakesProductCache).where(BrakesProductCache.product_code == clean_code)
)
cached = cache_result.scalar_one_or_none()
now = datetime.utcnow()
cache_ttl = timedelta(days=30)
not_found_ttl = timedelta(days=7)
if cached and not force:
age = now - cached.fetched_at
if cached.not_found and age < not_found_ttl:
return {"found": False, "product_code": clean_code, "suggested_flags": [], "none_category_ids": []}
if not cached.not_found and age < cache_ttl:
# Serve from cache — build suggestions
contains = json.loads(cached.contains_allergens) if cached.contains_allergens else []
dietary = json.loads(cached.dietary_info) if cached.dietary_info else []
suggestions, none_cat_ids = await _build_brakes_suggestions(
db, user.kitchen_id, contains, cached.ingredients_text or "", dietary
)
return {
"found": True,
"product_code": clean_code,
"product_name": cached.product_name or "",
"ingredients_text": cached.ingredients_text or "",
"contains_allergens": contains,
"suitable_for": dietary,
"suggested_flags": suggestions,
"none_category_ids": none_cat_ids,
}
# Cache miss or stale — fetch from website
product = await fetch_brakes_product(clean_code)
if product is None or (not product.ingredients_text and not product.product_name):
# 404 or empty page — cache as not_found
if cached:
cached.not_found = True
cached.fetched_at = now
else:
db.add(BrakesProductCache(
product_code=clean_code,
not_found=True,
fetched_at=now,
))
await db.commit()
return {"found": False, "product_code": clean_code, "suggested_flags": [], "none_category_ids": []}
# Store in cache
contains_json = json.dumps(product.contains_allergens)
dietary_json = json.dumps(product.suitable_for)
if cached:
cached.product_name = product.product_name
cached.ingredients_text = product.ingredients_text
cached.contains_allergens = contains_json
cached.dietary_info = dietary_json
cached.not_found = False
cached.fetched_at = now
else:
db.add(BrakesProductCache(
product_code=clean_code,
product_name=product.product_name,
ingredients_text=product.ingredients_text,
contains_allergens=contains_json,
dietary_info=dietary_json,
not_found=False,
fetched_at=now,
))
await db.commit()
# Build suggestions
suggestions, none_cat_ids = await _build_brakes_suggestions(
db, user.kitchen_id, product.contains_allergens, product.ingredients_text, product.suitable_for
)
return {
"found": True,
"product_code": clean_code,
"product_name": product.product_name,
"ingredients_text": product.ingredients_text,
"contains_allergens": product.contains_allergens,
"suitable_for": product.suitable_for,
"suggested_flags": suggestions,
"none_category_ids": none_cat_ids,
}
async def _build_brakes_suggestions(
db: AsyncSession,
kitchen_id: int,
contains_allergens: list[str],
ingredients_text: str,
suitable_for: list[str] | None = None,
) -> list[dict]:
"""Build allergen flag suggestions from Brakes 'Contains' statement + keyword matching + dietary suitability."""
# Get all flags for this kitchen
flags_result = await db.execute(
select(FoodFlag)
.options(selectinload(FoodFlag.category))
.where(FoodFlag.kitchen_id == kitchen_id)
)
all_flags = flags_result.scalars().all()
flag_by_name = {f.name.lower(): f for f in all_flags}
suggestions: dict[int, dict] = {}
def _find_flag(name: str):
"""Match flag by exact name, then try singular/plural variants."""
n = name.lower().strip()
if n in flag_by_name:
return flag_by_name[n]
# Try removing trailing 's' (Eggs -> Egg, Crustaceans -> Crustacean)
if n.endswith("s") and n[:-1] in flag_by_name:
return flag_by_name[n[:-1]]
# Try adding 's' (Egg -> Eggs, Peanut -> Peanuts)
if f"{n}s" in flag_by_name:
return flag_by_name[f"{n}s"]
# Try 'es' removal (Mollusces -> Mollusc)
if n.endswith("es") and n[:-2] in flag_by_name:
return flag_by_name[n[:-2]]
return None
# 1. Direct match from "Contains" statement (high confidence)
for allergen_name in contains_allergens:
flag = _find_flag(allergen_name)
if flag:
suggestions[flag.id] = {
"flag_id": flag.id,
"flag_name": flag.name,
"flag_code": flag.code,
"category_name": flag.category.name if flag.category else "",
"source": "contains",
"matched_keywords": [],
}
# 1b. Dietary suitability — "Suitable for Vegetarians" / "Suitable for Vegans"
if suitable_for:
for diet in suitable_for:
flag = _find_flag(diet)
if flag and flag.id not in suggestions:
suggestions[flag.id] = {
"flag_id": flag.id,
"flag_name": flag.name,
"flag_code": flag.code,
"category_name": flag.category.name if flag.category else "",
"source": "dietary",
"matched_keywords": [],
}
# 2. Keyword matching against full ingredients text (catches extras)
if ingredients_text:
kw_result = await db.execute(
select(AllergenKeyword)
.options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
.where(AllergenKeyword.kitchen_id == kitchen_id)
)
keywords = kw_result.scalars().all()
keyword_matches = match_allergen_keywords(ingredients_text, keywords)
for km in keyword_matches:
fid = km["flag_id"]
if fid not in suggestions:
km["source"] = "keyword"
suggestions[fid] = km
else:
# Already matched via "contains" — append keyword info
suggestions[fid]["matched_keywords"] = km.get("matched_keywords", [])
# 3. Determine none_category_ids: when Brakes says "Contains: None of the 14 Food Allergens"
# (empty contains_allergens but non-empty ingredients_text = product found with no allergens)
none_category_ids = []
if not contains_allergens and ingredients_text:
cat_result = await db.execute(
select(FoodFlagCategory.id).where(
FoodFlagCategory.kitchen_id == kitchen_id,
FoodFlagCategory.propagation_type == "contains",
)
)
none_category_ids = [r for r in cat_result.scalars().all()]
return list(suggestions.values()), none_category_ids