MME-4 Replace recipe embedding job with recipe summary job; rename recipe_embedding to recipe_summary and add column.

This commit is contained in:
Jesse Brault 2026-02-01 16:17:35 -06:00
parent 164a3aa5f0
commit fa7afbaa76
14 changed files with 132 additions and 86 deletions

View File

@ -23,8 +23,7 @@ import java.util.UUID;
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes; import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
// TODO: test mainImage included // TODO: test mainImage included
// TODO: test prep/cooking/total times included // TODO: test prep/cooking/total times included
@ -61,7 +60,7 @@ public class RecipeServiceTests {
.rawText("Hello!") .rawText("Hello!")
.isPublic(isPublic) .isPublic(isPublic)
.build(); .build();
return this.recipeService.create(owner, spec); return this.recipeService.create(owner, spec, false);
} }
@Test @Test
@ -259,7 +258,7 @@ public class RecipeServiceTests {
.title("My Recipe") .title("My Recipe")
.rawText("# A Heading") .rawText("# A Heading")
.build(); .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 String newRawText = "# A Heading\n## A Subheading";
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe) final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
.rawText(newRawText) .rawText(newRawText)

View File

@ -83,7 +83,7 @@ public class RecipesControllerTests {
.rawText("# Hello, World!") .rawText("# Hello, World!")
.isPublic(isPublic) .isPublic(isPublic)
.build(); .build();
return this.recipeService.create(owner, spec); return this.recipeService.create(owner, spec, false);
} }
private String getAccessToken(User user) throws LoginException { private String getAccessToken(User user) throws LoginException {
@ -261,7 +261,7 @@ public class RecipesControllerTests {
.rawText("# Hello, World!") .rawText("# Hello, World!")
.mainImage(hal9000) .mainImage(hal9000)
.build(); .build();
Recipe recipe = this.recipeService.create(owner, createSpec); Recipe recipe = this.recipeService.create(owner, createSpec, false);
final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.builder() final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.builder()
.title("Updated Test Recipe") .title("Updated Test Recipe")

View File

@ -18,7 +18,7 @@ import java.util.UUID;
import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest @SpringBootTest
@ExtendWith(PostgresTestsExtension.class) @ExtendWith(PostgresTestsExtension.class)
@ -49,7 +49,7 @@ public class RecipeStarServiceTests {
.rawText("My great recipe has five ingredients.") .rawText("My great recipe has five ingredients.")
.isPublic(true) .isPublic(true)
.build(); .build();
return this.recipeService.create(owner, spec); return this.recipeService.create(owner, spec, false);
} }
@Test @Test

View File

@ -3,8 +3,7 @@ package app.mealsmadeeasy.api;
import app.mealsmadeeasy.api.job.JobService; import app.mealsmadeeasy.api.job.JobService;
import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeRepository; import app.mealsmadeeasy.api.recipe.RecipeRepository;
import app.mealsmadeeasy.api.recipe.job.RecipeEmbeddingJobHandler; import app.mealsmadeeasy.api.recipe.job.RecipeSummaryJobHandler;
import app.mealsmadeeasy.api.recipe.job.RecipeEmbeddingJobPayload;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
@ -23,9 +22,12 @@ public class BackfillRecipeEmbeddings implements ApplicationRunner {
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
final List<Recipe> recipeEntities = this.recipeRepository.findAllByEmbeddingIsNull(); final List<Recipe> recipeEntities = this.recipeRepository.findAllBySummaryIsNull();
for (final Recipe recipe : recipeEntities) { 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(); this.recipeRepository.flush();
} }

View File

@ -109,7 +109,7 @@ public class DevConfiguration {
.isPublic(frontMatter.isPublic) .isPublic(frontMatter.isPublic)
.mainImage(mainImage) .mainImage(mainImage)
.build(); .build();
final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec); final Recipe recipe = this.recipeService.create(testUser, recipeCreateSpec, false);
logger.info("Created recipe {}", recipe); logger.info("Created recipe {}", recipe);
} }
} }

View File

@ -11,10 +11,14 @@ import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.ollama.api.OllamaChatOptions; import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class InferenceService { public class InferenceService {
private static final String OLLAMA_MODEL = "gemma3:latest";
private final ChatModel chatModel; private final ChatModel chatModel;
public <T> @Nullable T extract( public <T> @Nullable T extract(
@ -23,7 +27,7 @@ public class InferenceService {
BeanOutputConverter<T> converter BeanOutputConverter<T> converter
) { ) {
final ChatOptions extractChatOptions = OllamaChatOptions.builder() final ChatOptions extractChatOptions = OllamaChatOptions.builder()
.model("gemma3:latest") .model(OLLAMA_MODEL)
.format(converter.getJsonSchemaMap()) .format(converter.getJsonSchemaMap())
.build(); .build();
final Prompt extractPrompt = Prompt.builder() final Prompt extractPrompt = Prompt.builder()
@ -38,4 +42,16 @@ public class InferenceService {
return converter.convert(extractContent); return converter.convert(extractContent);
} }
public @Nullable String infer(List<Message> 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();
}
} }

View File

@ -76,7 +76,7 @@ public class Recipe {
private Image mainImage; private Image mainImage;
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private RecipeEmbedding embedding; private RecipeSummary summary;
@PrePersist @PrePersist
private void prePersist() { private void prePersist() {

View File

@ -41,7 +41,7 @@ public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
@Query("SELECT r FROM Recipe r WHERE r.isPublic OR r.owner = ?1 OR ?1 MEMBER OF r.viewers") @Query("SELECT r FROM Recipe r WHERE r.isPublic OR r.owner = ?1 OR ?1 MEMBER OF r.viewers")
Slice<Recipe> findAllViewableBy(User viewer, Pageable pageable); Slice<Recipe> findAllViewableBy(User viewer, Pageable pageable);
List<Recipe> findAllByEmbeddingIsNull(); List<Recipe> findAllBySummaryIsNull();
@Query( @Query(
nativeQuery = true, nativeQuery = true,

View File

@ -6,10 +6,9 @@ import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.job.JobService; import app.mealsmadeeasy.api.job.JobService;
import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; 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.RecipeInferJobHandler;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload; 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.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
@ -46,7 +45,7 @@ public class RecipeService {
private final RecipeDraftRepository recipeDraftRepository; private final RecipeDraftRepository recipeDraftRepository;
private final JobService jobService; 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(); final Recipe draft = new Recipe();
draft.setOwner(owner); draft.setOwner(owner);
draft.setSlug(spec.getSlug()); draft.setSlug(spec.getSlug());
@ -55,10 +54,19 @@ public class RecipeService {
draft.setMainImage(spec.getMainImage()); draft.setMainImage(spec.getMainImage());
draft.setIsPublic(spec.isPublic()); draft.setIsPublic(spec.isPublic());
final Recipe saved = this.recipeRepository.save(draft); 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; return saved;
} }
public Recipe create(User owner, RecipeCreateSpec spec) {
return this.create(owner, spec, false);
}
private Recipe getById(Integer id) { private Recipe getById(Integer id) {
return this.recipeRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, id)); return this.recipeRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, id));
} }
@ -324,7 +332,7 @@ public class RecipeService {
.isPublic(false) .isPublic(false)
.mainImage(recipeDraft.getMainImage()) .mainImage(recipeDraft.getMainImage())
.build(); .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 this.recipeDraftRepository.deleteById(draftId); // delete old draft
return recipe; return recipe;
} }

View File

@ -10,9 +10,9 @@ import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@Entity @Entity
@Table(name = "recipe_embedding") @Table(name = "recipe_summary")
@Data @Data
public class RecipeEmbedding { public class RecipeSummary {
@Id @Id
private Integer id; private Integer id;
@ -22,10 +22,12 @@ public class RecipeEmbedding {
@JoinColumn(name = "recipe_id") @JoinColumn(name = "recipe_id")
private Recipe recipe; private Recipe recipe;
@Column(columnDefinition = "TEXT")
private @Nullable String summary;
@JdbcTypeCode(SqlTypes.VECTOR) @JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 1024) @Array(length = 1024)
@Nullable private @Nullable float[] embedding;
private float[] embedding;
@Column(nullable = false) @Column(nullable = false)
private OffsetDateTime timestamp; private OffsetDateTime timestamp;

View File

@ -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<RecipeEmbeddingJobPayload> {
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<RecipeEmbeddingJobPayload> 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 = "<h1>" + recipe.getTitle() + "</h1>\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());
}
}

View File

@ -1,3 +0,0 @@
package app.mealsmadeeasy.api.recipe.job;
public record RecipeEmbeddingJobPayload(Integer recipeId) {}

View File

@ -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<RecipeSummaryJobHandler.RecipeSummaryJobPayload> {
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<RecipeSummaryJobPayload> 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(
"<title>" + recipe.getTitle() + "</title>\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());
}
}

View File

@ -0,0 +1,2 @@
ALTER TABLE recipe_embedding RENAME TO recipe_summary;
ALTER TABLE recipe_summary ADD COLUMN summary TEXT;