Files
Cibello-app/apps/worker/src/processors/moderation.ts
T
2026-08-05 19:21:11 +07:00

102 lines
3.8 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));
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<number>`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));
}