d2faf98ac1
Fixes the 13-item codebase health scan backlog: wraps meal-plan generation in a transaction, adds missing userId/GIN indexes, fixes an IPv6-parsing gap in the webhook SSRF guard (and an identical duplicated bug in the AI URL-import path, now consolidated onto one implementation), paginates the collections list, dedupes the AI recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key rotation, gets `pnpm typecheck` actually working, and adds test coverage for the previously-untested admin/webhooks routes. Two flagged issues (collection removeRecipeId IDOR, tier-limit race) turned out to already be fixed/non-issues on inspection — noted in TODO.md rather than silently dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
68 lines
2.7 KiB
TypeScript
68 lines
2.7 KiB
TypeScript
import { generateObject } from "ai";
|
|
import { z } from "zod";
|
|
import { resolveModel, type AiConfig } from "../factory";
|
|
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
|
|
|
const AdaptedRecipeSchema = z.object({
|
|
title: z.string(),
|
|
description: z.string(),
|
|
baseServings: z.number().int().min(1).max(100),
|
|
prepMins: z.number().int().min(0).optional(),
|
|
cookMins: z.number().int().min(0).optional(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]),
|
|
dietaryTags: dietaryTagsSchema,
|
|
ingredients: z.array(ingredientSchema(z.number())),
|
|
steps: z.array(stepSchema),
|
|
adaptationNotes: z.string(),
|
|
});
|
|
|
|
export type AdaptedRecipe = z.infer<typeof AdaptedRecipeSchema>;
|
|
|
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
|
|
|
export async function adaptRecipe(
|
|
recipe: {
|
|
title: string;
|
|
description?: string | null;
|
|
baseServings: number;
|
|
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null }>;
|
|
steps: Array<{ instruction: string }>;
|
|
},
|
|
constraints: {
|
|
excludeIngredients: string[];
|
|
extraConstraints?: string;
|
|
},
|
|
config?: AiConfig & { userContext?: string },
|
|
locale?: string
|
|
): Promise<AdaptedRecipe> {
|
|
const model = resolveModel(config);
|
|
const lang = LANG[locale ?? "en"] ?? "English";
|
|
|
|
const recipeText = [
|
|
`Title: ${recipe.title}`,
|
|
recipe.description ? `Description: ${recipe.description}` : "",
|
|
`Servings: ${recipe.baseServings}`,
|
|
`Ingredients:\n${recipe.ingredients.map((i) => ` - ${i.quantity ? `${i.quantity} ` : ""}${i.unit ? `${i.unit} ` : ""}${i.rawName}`).join("\n")}`,
|
|
`Steps:\n${recipe.steps.map((s, i) => ` ${i + 1}. ${s.instruction}`).join("\n")}`,
|
|
].filter(Boolean).join("\n");
|
|
|
|
const constraintText = [
|
|
constraints.excludeIngredients.length > 0
|
|
? `MUST NOT contain or use: ${constraints.excludeIngredients.join(", ")}. Find suitable substitutes that preserve the dish character.`
|
|
: "",
|
|
constraints.extraConstraints?.trim()
|
|
? `Additional constraints: ${constraints.extraConstraints}`
|
|
: "",
|
|
].filter(Boolean).join("\n");
|
|
|
|
const { object } = await generateObject({
|
|
model,
|
|
schema: AdaptedRecipeSchema,
|
|
system:
|
|
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
|
prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`,
|
|
});
|
|
|
|
return object;
|
|
}
|