FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2309 lines
88 KiB
Python
2309 lines
88 KiB
Python
"""
|
||
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
|