feat(ops): /ops/v1/summary med ai/aktivering/engagemang/betalning/jobb/sakerhet + canary
- Nya toppnivåblock i /ops/v1/summary: ai_scan, aktivering, engagemang, betalning, jobb, sakerhet. Alla värden läses från DB/cache; null där data saknas, inga påhittade värden. - AI-kostnad i USD mikrocent (native); intäkter fortsatt SEK-öre. - product_analytics_events är källa för volym, latens, lyckandegrad, felfrekvens, aktivering, retention och engagemang. - Nya händelser: scan_started (API) och scan_failed/scan_completed med latencyMs + felkod (worker). latencyMs flödar nu in i scan_completed. - Safety canary-jobb varje timme: re-härleder allergener för alla recept, räknar överifierade publika recept och food-safety-lint; skriver EN rad till ops_safety_canary. Endpointen läser endast sista raden. - Cache-refresh-jobb var 60 s skriver hela summariet till Redis; endpointen serverar cachen med 503 vid cache-miss. - Bearer-token-skydd med OPS_TOKEN; HTTPS-tvång i produktion; ingen PII. - Tester för endpoint, auth, cache-miss och safety canary.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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",
|
||||
"egg",
|
||||
"cod",
|
||||
"salmon",
|
||||
"shrimp",
|
||||
"anchovy_swedish",
|
||||
"pickled_herring",
|
||||
]);
|
||||
|
||||
const SAFE_COOKING_KEYWORDS_SV = [
|
||||
/\bgenomstekt\b/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"),
|
||||
ne(schema.recipes.verificationStatus, "verified"),
|
||||
),
|
||||
);
|
||||
const overifieradeVisade = publicUnverified[0]?.count ?? 0;
|
||||
|
||||
await ctx.db.insert(schema.opsSafetyCanary).values({
|
||||
allergenInvariantBrott,
|
||||
overifieradeVisade,
|
||||
foodSafetyLintAvvisade7d,
|
||||
});
|
||||
|
||||
return { allergenInvariantBrott, overifieradeVisade, foodSafetyLintAvvisade7d };
|
||||
}
|
||||
Reference in New Issue
Block a user