ba776daaa1
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.
103 lines
3.9 KiB
TypeScript
103 lines
3.9 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));
|
||
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));
|
||
}
|