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 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)

View File

@ -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")

View File

@ -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

View File

@ -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<Recipe> recipeEntities = this.recipeRepository.findAllByEmbeddingIsNull();
final List<Recipe> 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();
}

View File

@ -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);
}
}

View File

@ -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 <T> @Nullable T extract(
@ -23,7 +27,7 @@ public class InferenceService {
BeanOutputConverter<T> 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<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;
@OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private RecipeEmbedding embedding;
private RecipeSummary summary;
@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")
Slice<Recipe> findAllViewableBy(User viewer, Pageable pageable);
List<Recipe> findAllByEmbeddingIsNull();
List<Recipe> findAllBySummaryIsNull();
@Query(
nativeQuery = true,

View File

@ -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;
}

View File

@ -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;

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;