Files
Cibello-app/apps/worker/src/processors/safety-canary.ts
T
Sven (AAMOS AI) 4687f46384 Phase 1 ops core: base blocks, feedback, canary fixes
- Add app/ekonomi/anvandare/prenumerationer/butik/feedback blocks to computeOpsSummary.
- New feedback table, POST /api/feedback, GDPR erasure wiring.
- Canary: only count unverified/rejected published recipes; treat verified/editorial as safe.
- Add 'rejected' recipe verification status enum value.
- Fix food-safety lint baseline: remove egg/cured fish from raw-protein list, relax
  genomstekt regex, add safe-cooking phrases to seed recipes.
- Seed recipes now get verificationStatus=editorial.
- Tests: ops summary blocks, feedback route, me.residual cleanup.
2026-08-10 06:35:51 +07:00

183 lines
5.4 KiB
TypeScript

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<SafetyCanaryResult> {
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<string, IngredientSafetyInfo>();
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<number>`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 };
}