102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
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));
|
||
}
|