Files
Cibello-app/apps/worker/src/processors/moderation.ts
T
Claude ba776daaa1 feat(egna-recept): auto-publicera anvandarrecept efter AI-kontroll + dubblettkoll
Publiceringsflodet for anvandarinskickade recept gar nu hela vagen
automatiskt: submitted -> AI-granskning -> dubblettkontroll -> published.
Bara AI-underkanda eller exakta dubbletter (jaccard >= 0.9) stoppas.
Tidigare hamnade allt i in_moderation for manuell granskning.
2026-08-19 21:17:01 +00:00

103 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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));
}