import { eq, and, ne, sql, desc } from "drizzle-orm"; import { schema } from "@app/database"; import { deriveRecipeAllergens, type IngredientSafetyInfo } from "@app/recipe-engine"; import type { WorkerContext } from "../context.js"; const RAW_PROTEIN_REQUIRING_SAFE_COOKING = new Set([ "chicken_breast", "chicken_thigh", "pork_loin", "minced_beef", "minced_mixed", "meatball_pork_beef", "falukorv", "cod", "salmon", "shrimp", ]); const SAFE_COOKING_KEYWORDS_SV = [ /\bgenomstek/i, /\bgenomkokt\b/i, /\bgenomgrillad\b/i, /\bgenomv\w+\b/i, /\binte längre rosa\b/i, /\binte rosa\b/i, /\bflagnar\b/i, /\bkärntemperatur\b/i, /\binnertemperatur\b/i, /\btemperatur\b/i, /\b°\s*c\b/i, /\bgrader\b/i, /\btill(?:s)? den är klar\b/i, /\btill(?:s)? köttet släpper vätskan\b/i, /\b72\s*c?\b/i, /\b74\s*c?\b/i, /\b75\s*c?\b/i, /\b63\s*c?\b/i, /\b65\s*c?\b/i, /\b70\s*c?\b/i, ]; const UNSAFE_APPEARANCE_ONLY_SV = [ /\bgyllenbrun\b/i, /\bgyllene\b/i, /\bkrispig\b/i, /\bkrispiga\b/i, /\bfint färg\b/i, /\bfint färgade\b/i, /\bfärgad\b/i, /\bfräsch\b/i, /\bfräscha\b/i, ]; function requiresSafeCookingStep(ingredientIds: string[]): boolean { return ingredientIds.some((id) => RAW_PROTEIN_REQUIRING_SAFE_COOKING.has(id)); } function hasSafeCookingStep(steps: Array<{ instructionSv: string }>): boolean { return steps.some((s) => { const instruction = s.instructionSv; const hasPositive = SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction)); const onlyAppearance = UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) && !SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction)); return hasPositive && !onlyAppearance; }); } function toSafetyInfo(ing: { id: string; allergens: string[]; isVegan: boolean; isVegetarian: boolean; containsGluten: boolean; containsLactose: boolean; isPork: boolean; isBeef: boolean; isAlcohol: boolean; }): IngredientSafetyInfo { return { id: ing.id, allergens: ing.allergens as IngredientSafetyInfo["allergens"], isVegan: ing.isVegan, isVegetarian: ing.isVegetarian, containsGluten: ing.containsGluten, containsLactose: ing.containsLactose, isPork: ing.isPork, isBeef: ing.isBeef, isAlcohol: ing.isAlcohol, dataVerified: true, }; } export interface SafetyCanaryResult { allergenInvariantBrott: number; overifieradeVisade: number; foodSafetyLintAvvisade7d: number; } /** * Hourly safety canary: deterministic re-derivation of allergens and a * food-safety lint sample. Results are persisted in ops_safety_canary; * /ops/v1/summary only reads the latest row. */ export async function processSafetyCanary(ctx: WorkerContext): Promise { const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const allIngredients = await ctx.db .select({ id: schema.canonicalIngredients.id, allergens: schema.canonicalIngredients.allergens, isVegan: schema.canonicalIngredients.isVegan, isVegetarian: schema.canonicalIngredients.isVegetarian, containsGluten: schema.canonicalIngredients.containsGluten, containsLactose: schema.canonicalIngredients.containsLactose, isPork: schema.canonicalIngredients.isPork, isBeef: schema.canonicalIngredients.isBeef, isAlcohol: schema.canonicalIngredients.isAlcohol, }) .from(schema.canonicalIngredients); const infoMap = new Map(); for (const ing of allIngredients) { infoMap.set(ing.id, toSafetyInfo(ing)); } const recipes = await ctx.db .select({ id: schema.recipes.id, allergens: schema.recipes.allergens, status: schema.recipes.status, verificationStatus: schema.recipes.verificationStatus, updatedAt: schema.recipes.updatedAt, }) .from(schema.recipes); let allergenInvariantBrott = 0; let foodSafetyLintAvvisade7d = 0; for (const recipe of recipes) { const ingredients = await ctx.db .select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId }) .from(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, recipe.id)); const ids = ingredients.map((i) => i.canonicalIngredientId); const derived = [...deriveRecipeAllergens(ids, infoMap)].sort(); const stored = [...(recipe.allergens ?? [])].sort(); if (JSON.stringify(derived) !== JSON.stringify(stored)) { allergenInvariantBrott++; } if (recipe.updatedAt && new Date(recipe.updatedAt) >= sevenDaysAgo) { const steps = await ctx.db .select({ instructionSv: schema.recipeSteps.instructionSv }) .from(schema.recipeSteps) .where(eq(schema.recipeSteps.recipeId, recipe.id)); if (requiresSafeCookingStep(ids) && !hasSafeCookingStep(steps)) { foodSafetyLintAvvisade7d++; } } } const publicUnverified = await ctx.db .select({ count: sql`count(*)::int` }) .from(schema.recipes) .where( and( eq(schema.recipes.status, "published"), sql`${schema.recipes.verificationStatus} IN ('unverified', 'rejected')`, ), ); const overifieradeVisade = publicUnverified[0]?.count ?? 0; await ctx.db.insert(schema.opsSafetyCanary).values({ allergenInvariantBrott, overifieradeVisade, foodSafetyLintAvvisade7d, }); return { allergenInvariantBrott, overifieradeVisade, foodSafetyLintAvvisade7d }; }