From fa7afbaa768989aab89aa4ddc9a05ce716982e03 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Sun, 1 Feb 2026 16:17:35 -0600 Subject: [PATCH] MME-4 Replace recipe embedding job with recipe summary job; rename recipe_embedding to recipe_summary and add column. --- .../api/recipe/RecipeServiceTests.java | 7 +- .../api/recipe/RecipesControllerTests.java | 4 +- .../recipe/star/RecipeStarServiceTests.java | 4 +- .../api/BackfillRecipeEmbeddings.java | 10 ++- .../mealsmadeeasy/api/DevConfiguration.java | 2 +- .../api/ai/InferenceService.java | 18 ++++- .../app/mealsmadeeasy/api/recipe/Recipe.java | 2 +- .../api/recipe/RecipeRepository.java | 2 +- .../api/recipe/RecipeService.java | 18 +++-- ...ecipeEmbedding.java => RecipeSummary.java} | 10 ++- .../recipe/job/RecipeEmbeddingJobHandler.java | 58 -------------- .../recipe/job/RecipeEmbeddingJobPayload.java | 3 - .../recipe/job/RecipeSummaryJobHandler.java | 78 +++++++++++++++++++ .../db/migration/V9__recipe_summary.sql | 2 + 14 files changed, 132 insertions(+), 86 deletions(-) rename src/main/java/app/mealsmadeeasy/api/recipe/{RecipeEmbedding.java => RecipeSummary.java} (77%) delete mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobHandler.java delete mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobPayload.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeSummaryJobHandler.java create mode 100644 src/main/resources/db/migration/V9__recipe_summary.sql diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java index d00f6e4..c07b3f3 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java @@ -23,8 +23,7 @@ import java.util.UUID; import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; // TODO: test mainImage included // TODO: test prep/cooking/total times included @@ -61,7 +60,7 @@ public class RecipeServiceTests { .rawText("Hello!") .isPublic(isPublic) .build(); - return this.recipeService.create(owner, spec); + return this.recipeService.create(owner, spec, false); } @Test @@ -259,7 +258,7 @@ public class RecipeServiceTests { .title("My Recipe") .rawText("# A Heading") .build(); - Recipe recipe = this.recipeService.create(owner, createSpec); + Recipe recipe = this.recipeService.create(owner, createSpec, false); final String newRawText = "# A Heading\n## A Subheading"; final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe) .rawText(newRawText) diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java index aee17ae..1a7d5c2 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java @@ -83,7 +83,7 @@ public class RecipesControllerTests { .rawText("# Hello, World!") .isPublic(isPublic) .build(); - return this.recipeService.create(owner, spec); + return this.recipeService.create(owner, spec, false); } private String getAccessToken(User user) throws LoginException { @@ -261,7 +261,7 @@ public class RecipesControllerTests { .rawText("# Hello, World!") .mainImage(hal9000) .build(); - Recipe recipe = this.recipeService.create(owner, createSpec); + Recipe recipe = this.recipeService.create(owner, createSpec, false); final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.builder() .title("Updated Test Recipe") diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java index 7325650..ad26860 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/star/RecipeStarServiceTests.java @@ -18,7 +18,7 @@ import java.util.UUID; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @ExtendWith(PostgresTestsExtension.class) @@ -49,7 +49,7 @@ public class RecipeStarServiceTests { .rawText("My great recipe has five ingredients.") .isPublic(true) .build(); - return this.recipeService.create(owner, spec); + return this.recipeService.create(owner, spec, false); } @Test diff --git a/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java b/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java index c729787..383586e 100644 --- a/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java +++ b/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java @@ -3,8 +3,7 @@ package app.mealsmadeeasy.api; import app.mealsmadeeasy.api.job.JobService; import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.RecipeRepository; -import app.mealsmadeeasy.api.recipe.job.RecipeEmbeddingJobHandler; -import app.mealsmadeeasy.api.recipe.job.RecipeEmbeddingJobPayload; +import app.mealsmadeeasy.api.recipe.job.RecipeSummaryJobHandler; import lombok.RequiredArgsConstructor; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -23,9 +22,12 @@ public class BackfillRecipeEmbeddings implements ApplicationRunner { @Override public void run(ApplicationArguments args) { - final List recipeEntities = this.recipeRepository.findAllByEmbeddingIsNull(); + final List recipeEntities = this.recipeRepository.findAllBySummaryIsNull(); for (final Recipe recipe : recipeEntities) { - this.jobService.create(RecipeEmbeddingJobHandler.JOB_KEY, new RecipeEmbeddingJobPayload(recipe.getId())); + this.jobService.create( + RecipeSummaryJobHandler.JOB_KEY, + new RecipeSummaryJobHandler.RecipeSummaryJobPayload(recipe.getId()) + ); } this.recipeRepository.flush(); } diff --git a/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java b/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java index 6663b85..3b7fe3a 100644 --- a/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java +++ b/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java @@ -109,7 +109,7 @@ public class DevConfiguration { .isPublic(frontMatter.isPublic) .mainImage(mainImage) .build(); - final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec); + final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec, false); logger.info("Created recipe {}", recipe); } } diff --git a/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java b/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java index ce3393d..6cc228a 100644 --- a/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java +++ b/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java @@ -11,10 +11,14 @@ import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.ollama.api.OllamaChatOptions; import org.springframework.stereotype.Component; +import java.util.List; + @Component @RequiredArgsConstructor public class InferenceService { + private static final String OLLAMA_MODEL = "gemma3:latest"; + private final ChatModel chatModel; public @Nullable T extract( @@ -23,7 +27,7 @@ public class InferenceService { BeanOutputConverter converter ) { final ChatOptions extractChatOptions = OllamaChatOptions.builder() - .model("gemma3:latest") + .model(OLLAMA_MODEL) .format(converter.getJsonSchemaMap()) .build(); final Prompt extractPrompt = Prompt.builder() @@ -38,4 +42,16 @@ public class InferenceService { return converter.convert(extractContent); } + public @Nullable String infer(List messages) { + final ChatOptions chatOptions = OllamaChatOptions.builder() + .model(OLLAMA_MODEL) + .build(); + final Prompt prompt = Prompt.builder() + .messages(messages) + .chatOptions(chatOptions) + .build(); + final ChatResponse chatResponse = this.chatModel.call(prompt); + return chatResponse.getResult().getOutput().getText(); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java index 42e3964..509d642 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java @@ -76,7 +76,7 @@ public class Recipe { private Image mainImage; @OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - private RecipeEmbedding embedding; + private RecipeSummary summary; @PrePersist private void prePersist() { diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java index 18c9add..0a5d817 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java @@ -41,7 +41,7 @@ public interface RecipeRepository extends JpaRepository { @Query("SELECT r FROM Recipe r WHERE r.isPublic OR r.owner = ?1 OR ?1 MEMBER OF r.viewers") Slice findAllViewableBy(User viewer, Pageable pageable); - List findAllByEmbeddingIsNull(); + List findAllBySummaryIsNull(); @Query( nativeQuery = true, diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index fdd5a16..7366124 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -6,10 +6,9 @@ import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.job.JobService; import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; -import app.mealsmadeeasy.api.recipe.job.RecipeEmbeddingJobHandler; -import app.mealsmadeeasy.api.recipe.job.RecipeEmbeddingJobPayload; import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler; import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload; +import app.mealsmadeeasy.api.recipe.job.RecipeSummaryJobHandler; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; @@ -46,7 +45,7 @@ public class RecipeService { private final RecipeDraftRepository recipeDraftRepository; private final JobService jobService; - public Recipe create(User owner, RecipeCreateSpec spec) { + public Recipe create(User owner, RecipeCreateSpec spec, boolean queueSummaryJob) { final Recipe draft = new Recipe(); draft.setOwner(owner); draft.setSlug(spec.getSlug()); @@ -55,10 +54,19 @@ public class RecipeService { draft.setMainImage(spec.getMainImage()); draft.setIsPublic(spec.isPublic()); final Recipe saved = this.recipeRepository.save(draft); - this.jobService.create(RecipeEmbeddingJobHandler.JOB_KEY, new RecipeEmbeddingJobPayload(saved.getId())); + if (queueSummaryJob) { + this.jobService.create( + RecipeSummaryJobHandler.JOB_KEY, + new RecipeSummaryJobHandler.RecipeSummaryJobPayload(saved.getId()) + ); + } return saved; } + public Recipe create(User owner, RecipeCreateSpec spec) { + return this.create(owner, spec, false); + } + private Recipe getById(Integer id) { return this.recipeRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, id)); } @@ -324,7 +332,7 @@ public class RecipeService { .isPublic(false) .mainImage(recipeDraft.getMainImage()) .build(); - final Recipe recipe = this.create(recipeDraft.getOwner(), spec); + final Recipe recipe = this.create(recipeDraft.getOwner(), spec, true); this.recipeDraftRepository.deleteById(draftId); // delete old draft return recipe; } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbedding.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSummary.java similarity index 77% rename from src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbedding.java rename to src/main/java/app/mealsmadeeasy/api/recipe/RecipeSummary.java index 8c4f5e0..ccfc50d 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbedding.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSummary.java @@ -10,9 +10,9 @@ import org.jetbrains.annotations.Nullable; import java.time.OffsetDateTime; @Entity -@Table(name = "recipe_embedding") +@Table(name = "recipe_summary") @Data -public class RecipeEmbedding { +public class RecipeSummary { @Id private Integer id; @@ -22,10 +22,12 @@ public class RecipeEmbedding { @JoinColumn(name = "recipe_id") private Recipe recipe; + @Column(columnDefinition = "TEXT") + private @Nullable String summary; + @JdbcTypeCode(SqlTypes.VECTOR) @Array(length = 1024) - @Nullable - private float[] embedding; + private @Nullable float[] embedding; @Column(nullable = false) private OffsetDateTime timestamp; diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobHandler.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobHandler.java deleted file mode 100644 index 8c62907..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobHandler.java +++ /dev/null @@ -1,58 +0,0 @@ -package app.mealsmadeeasy.api.recipe.job; - -import app.mealsmadeeasy.api.job.Job; -import app.mealsmadeeasy.api.job.JobHandler; -import app.mealsmadeeasy.api.recipe.Recipe; -import app.mealsmadeeasy.api.recipe.RecipeEmbedding; -import app.mealsmadeeasy.api.recipe.RecipeRepository; -import app.mealsmadeeasy.api.recipe.RecipeService; -import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; -import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.embedding.EmbeddingModel; -import org.springframework.stereotype.Component; - -import java.time.OffsetDateTime; - -@Component -@RequiredArgsConstructor -public class RecipeEmbeddingJobHandler implements JobHandler { - - public static final String JOB_KEY = "RECIPE_EMBEDDING"; - - private static final Logger logger = LoggerFactory.getLogger(RecipeEmbeddingJobHandler.class); - - private final RecipeRepository recipeRepository; - private final RecipeService recipeService; - private final EmbeddingModel embeddingModel; - - @Override - public Class getPayloadType() { - return RecipeEmbeddingJobPayload.class; - } - - @Override - public String getJobKey() { - return JOB_KEY; - } - - @Override - public void handle(Job job, RecipeEmbeddingJobPayload payload) { - logger.info("Calculating embedding for recipeId {}", payload.recipeId()); - final Recipe recipe = this.recipeRepository.findById(payload.recipeId()) - .orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, payload.recipeId())); - final String renderedMarkdown = this.recipeService.getRenderedMarkdown(recipe); - final String toEmbed = "

" + recipe.getTitle() + "

\n" + renderedMarkdown; - final float[] embedding = this.embeddingModel.embed(toEmbed); - - final RecipeEmbedding recipeEmbedding = new RecipeEmbedding(); - recipeEmbedding.setRecipe(recipe); - recipeEmbedding.setEmbedding(embedding); - recipeEmbedding.setTimestamp(OffsetDateTime.now()); - recipe.setEmbedding(recipeEmbedding); - this.recipeRepository.save(recipe); - logger.info("Finished calculating embedding for recipeId {}", payload.recipeId()); - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobPayload.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobPayload.java deleted file mode 100644 index caf7337..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeEmbeddingJobPayload.java +++ /dev/null @@ -1,3 +0,0 @@ -package app.mealsmadeeasy.api.recipe.job; - -public record RecipeEmbeddingJobPayload(Integer recipeId) {} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeSummaryJobHandler.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeSummaryJobHandler.java new file mode 100644 index 0000000..e5b7332 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeSummaryJobHandler.java @@ -0,0 +1,78 @@ +package app.mealsmadeeasy.api.recipe.job; + +import app.mealsmadeeasy.api.ai.InferenceService; +import app.mealsmadeeasy.api.job.Job; +import app.mealsmadeeasy.api.job.JobHandler; +import app.mealsmadeeasy.api.recipe.Recipe; +import app.mealsmadeeasy.api.recipe.RecipeRepository; +import app.mealsmadeeasy.api.recipe.RecipeSummary; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RecipeSummaryJobHandler implements JobHandler { + + public static final String JOB_KEY = "RECIPE_SUMMARY"; + + public record RecipeSummaryJobPayload(Integer recipeId) {} + + private static final Logger logger = LoggerFactory.getLogger(RecipeSummaryJobHandler.class); + + private final RecipeRepository recipeRepository; + private final InferenceService inferenceService; + private final EmbeddingModel embeddingModel; + + @Override + public Class getPayloadType() { + return RecipeSummaryJobPayload.class; + } + + @Override + public String getJobKey() { + return JOB_KEY; + } + + @Override + public void handle(Job job, RecipeSummaryJobPayload payload) { + logger.info("Summarizing recipe with id {}", payload.recipeId()); + final Recipe recipe = this.recipeRepository.findById(payload.recipeId()) + .orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, payload.recipeId())); + final @Nullable String summary = this.inferenceService.infer(List.of( + SystemMessage.builder() + .text("Summarize the given recipe in three sentences or less. Output your summary as plain text.") + .build(), + UserMessage.builder().text( + "" + recipe.getTitle() + "\n" + recipe.getRawText() + ).build() + )); + if (summary == null) { + throw new RuntimeException("Summary of recipe with id " + recipe.getId() + " came back null"); + } + logger.debug("Summary of recipe with id {}: {}", recipe.getId(), summary); + + // todo: save summary to db + + final float[] summaryEmbedding = this.embeddingModel.embed(summary); + + final RecipeSummary recipeSummary = new RecipeSummary(); + recipeSummary.setEmbedding(summaryEmbedding); + recipeSummary.setRecipe(recipe); + recipeSummary.setTimestamp(OffsetDateTime.now()); + recipe.setSummary(recipeSummary); + this.recipeRepository.saveAndFlush(recipe); + + logger.info("Finished summarizing and creating embedding for recipe with id {}", recipe.getId()); + } + +} diff --git a/src/main/resources/db/migration/V9__recipe_summary.sql b/src/main/resources/db/migration/V9__recipe_summary.sql new file mode 100644 index 0000000..39aacb4 --- /dev/null +++ b/src/main/resources/db/migration/V9__recipe_summary.sql @@ -0,0 +1,2 @@ +ALTER TABLE recipe_embedding RENAME TO recipe_summary; +ALTER TABLE recipe_summary ADD COLUMN summary TEXT;