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)); let exactDuplicate = false; 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"; if (classification === "duplicate") exactDuplicate = true; 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. Auto-publicering: AI:n granskar, dubblettkollar och lägger ut direkt. // Recept som AI:n underkänner (steg 1) eller som är EXAKTA dubbletter // (jaccard ≥ 0.9) publiceras inte – allt annat går live automatiskt. const flagsNote = result.status === "ok" && result.output ? result.output.flags.map((f) => `[${f.severity}] ${f.messageSv}`).join(" ") : "AI-kontroll kunde inte köras."; await ctx.db .update(schema.recipes) .set({ status: exactDuplicate ? "rejected" : "published", moderationNote: exactDuplicate ? `Publicerades inte – för lik ett befintligt recept. ${flagsNote}`.trim() : `Auto-publicerad efter AI-kontroll. ${flagsNote}`.trim(), updatedAt: new Date(), }) .where(eq(schema.recipes.id, recipeId)); }