import { and, eq, ne, sql } from "drizzle-orm"; import { schema } from "@app/database"; import type { WorkerContext } from "../context.js"; /** * Publiceringsflöde för användarrecept (spec §35): * submitted → AI-kontroll (AAMOS MODERATE_RECIPE) → dubblettkontroll (spec §36) * → in_moderation (mänsklig granskning i admin) eller direkt reject. */ export async function processModerateRecipe(ctx: WorkerContext, recipeId: string): Promise { const [recipe] = await ctx.db .select() .from(schema.recipes) .where(eq(schema.recipes.id, recipeId)) .limit(1); if (!recipe || recipe.status !== "submitted") return; const ingredients = await ctx.db .select() .from(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, recipeId)); const steps = await ctx.db .select() .from(schema.recipeSteps) .where(eq(schema.recipeSteps.recipeId, recipeId)) .orderBy(schema.recipeSteps.stepNumber); // 1. AI-kontroll (spec §35 steg 2) const result = await ctx.aamos.runTask("MODERATE_RECIPE", { titleSv: recipe.titleSv, descriptionSv: recipe.descriptionSv, ingredients: ingredients.map((i) => `${i.quantity} ${i.unit} ${i.displayNameSv}`), steps: steps.map((s) => s.instructionSv), }); if (result.status === "ok" && result.output?.recommendation === "reject") { await ctx.db .update(schema.recipes) .set({ status: "rejected", moderationNote: result.output.flags.map((f) => f.messageSv).join(" "), updatedAt: new Date(), }) .where(eq(schema.recipes.id, recipeId)); return; } // 2. Dubblettkontroll (spec §36): jämför ingrediensuppsättning + DNA const candidates = await ctx.db .select({ id: schema.recipes.id, dna: schema.recipes.dna, titleSv: schema.recipes.titleSv }) .from(schema.recipes) .where(and(eq(schema.recipes.status, "published"), ne(schema.recipes.id, recipeId))) .limit(500); const mySet = new Set(ingredients.map((i) => i.canonicalIngredientId)); for (const candidate of candidates) { const otherIngredients = await ctx.db .select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId }) .from(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, candidate.id)); const otherSet = new Set(otherIngredients.map((i) => i.canonicalIngredientId)); const intersection = [...mySet].filter((id) => otherSet.has(id)).length; const union = new Set([...mySet, ...otherSet]).size; const jaccard = union > 0 ? intersection / union : 0; if (jaccard >= 0.6) { const classification = jaccard >= 0.9 ? "duplicate" : jaccard >= 0.75 ? "variant" : "inspired"; await ctx.db .insert(schema.recipeSimilarities) .values({ recipeAId: recipeId, recipeBId: candidate.id, similarityScore: Math.round(jaccard * 100) / 100, classification, details: { method: "ingredient_jaccard", intersection, union }, }) .onConflictDoNothing(); } } // 3. Till mänsklig moderering (spec §35 steg 4). Vanliga rätter får ha // legitima varianter – dubbletter avgörs av människa, inte automatik. const flagsNote = result.status === "ok" && result.output ? result.output.flags.map((f) => `[${f.severity}] ${f.messageSv}`).join(" ") : "AI-kontrollen kunde inte köras – manuell granskning krävs."; const dupCount = await ctx.db .select({ count: sql`count(*)` }) .from(schema.recipeSimilarities) .where(eq(schema.recipeSimilarities.recipeAId, recipeId)); await ctx.db .update(schema.recipes) .set({ status: "in_moderation", moderationNote: `${flagsNote} Dubblettkandidater: ${Number(dupCount[0]?.count ?? 0)}.`.trim(), updatedAt: new Date(), }) .where(eq(schema.recipes.id, recipeId)); }