diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java index bef9350..38c19f9 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java @@ -1,23 +1,36 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.IntegrationTestsExtension; +import app.mealsmadeeasy.api.ai.InferenceService; +import app.mealsmadeeasy.api.ai.OcrService; import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.LoginException; +import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; +import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.MimeTypeUtils; +import java.util.List; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.Matchers.hasSize; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -38,6 +51,9 @@ public class RecipeDraftsControllerIntegrationTests { @Autowired private RecipeService recipeService; + @Autowired + private ObjectMapper objectMapper; + private static final String TEST_PASSWORD = "test"; private User seedUser() { @@ -114,4 +130,118 @@ public class RecipeDraftsControllerIntegrationTests { .andExpect(jsonPath("$", hasSize(2))); } + @Test + public void manualDraft_returnsDraft() throws Exception { + final User owner = this.seedUser(); + this.mockMvc.perform( + post("/recipe-drafts/manual") + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.created", is(notNullValue()))) + .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString()))) + .andExpect(jsonPath("$.owner.id", is(owner.getId()))); + } + + @Nested + public class AiDraftTestsWithMocks { + + @MockitoBean + private OcrService ocrService; + + @MockitoBean + private InferenceService inferenceService; + + @Test + public void whenAiDraft_returnsDraft() throws Exception { + final User owner = seedUser(); + final MockMultipartFile sourceFile = new MockMultipartFile( + "sourceFile", + "recipe.jpeg", + MimeTypeUtils.IMAGE_JPEG_VALUE, + AiDraftTestsWithMocks.class.getResourceAsStream("recipe.jpeg") + ); + mockMvc.perform( + multipart("/recipe-drafts/ai") + .file(sourceFile) + .param("sourceFileName", sourceFile.getOriginalFilename()) + .header("Authorization", "Bearer " + getAccessToken(owner)) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.created", is(notNullValue()))) + .andExpect(jsonPath("$.state", is(RecipeDraft.State.INFER.toString()))) + .andExpect(jsonPath("$.owner.id", is(owner.getId()))); + } + + } + + @Test + public void pollAiDraft_returnsDraft() throws Exception { + final User owner = this.seedUser(); + final MockMultipartFile sourceFile = new MockMultipartFile( + "sourceFile", + "recipe.jpeg", + MimeTypeUtils.IMAGE_JPEG_VALUE, + RecipeDraftsControllerIntegrationTests.class.getResourceAsStream("recipe.jpeg") + ); + final MvcResult multipartResult = this.mockMvc.perform( + multipart("/recipe-drafts/ai") + .file(sourceFile) + .param("sourceFileName", sourceFile.getOriginalFilename()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isCreated()) + .andReturn(); + + final String rawContent = multipartResult.getResponse().getContentAsString(); + final RecipeDraftView recipeDraftView = this.objectMapper.readValue(rawContent, RecipeDraftView.class); + + await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> { + this.mockMvc.perform( + get("/recipe-drafts/{id}", recipeDraftView.id()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString()))); + }); + } + + @Test + public void whenUpdate_returnsUpdated() throws Exception { + final User owner = this.seedUser(); + final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); + final RecipeDraftUpdateBody updateBody = RecipeDraftUpdateBody.builder() + .title("Test Title") + .slug("test-slug") + .preparationTime(15) + .cookingTime(30) + .totalTime(45) + .rawText("Hello, World!") + .ingredients(List.of( + RecipeDraftUpdateBody.IngredientDraftUpdateBody.builder() + .amount("1 Unit") + .name("Test Ingredient") + .notes("Separated") + .build() + )) + .build(); + this.mockMvc.perform( + put("/recipe-drafts/{id}", recipeDraft.getId()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + .header("Content-Type", "application/json") + .content(this.objectMapper.writeValueAsString(updateBody)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title", is("Test Title"))) + .andExpect(jsonPath("$.slug", is("test-slug"))) + .andExpect(jsonPath("$.preparationTime", is(15))) + .andExpect(jsonPath("$.cookingTime", is(30))) + .andExpect(jsonPath("$.totalTime", is(45))) + .andExpect(jsonPath("$.rawText", is("Hello, World!"))) + .andExpect(jsonPath("$.ingredients", hasSize(1))) + .andExpect(jsonPath("$.ingredients[0].amount", is("1 Unit"))) + .andExpect(jsonPath("$.ingredients[0].name", is("Test Ingredient"))) + .andExpect(jsonPath("$.ingredients[0].notes", is("Separated"))); + } + } diff --git a/src/integrationTest/resources/app/mealsmadeeasy/api/recipe/recipe.jpeg b/src/integrationTest/resources/app/mealsmadeeasy/api/recipe/recipe.jpeg new file mode 100644 index 0000000..fa6b65f Binary files /dev/null and b/src/integrationTest/resources/app/mealsmadeeasy/api/recipe/recipe.jpeg differ diff --git a/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java b/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java new file mode 100644 index 0000000..ce3393d --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java @@ -0,0 +1,41 @@ +package app.mealsmadeeasy.api.ai; + +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.ollama.api.OllamaChatOptions; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class InferenceService { + + private final ChatModel chatModel; + + public @Nullable T extract( + Message systemMessage, + Message userMessage, + BeanOutputConverter converter + ) { + final ChatOptions extractChatOptions = OllamaChatOptions.builder() + .model("gemma3:latest") + .format(converter.getJsonSchemaMap()) + .build(); + final Prompt extractPrompt = Prompt.builder() + .messages(systemMessage, userMessage) + .chatOptions(extractChatOptions) + .build(); + final ChatResponse extractResponse = this.chatModel.call(extractPrompt); + final String extractContent = extractResponse.getResult().getOutput().getText(); + if (extractContent == null) { + return null; + } + return converter.convert(extractContent); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/ai/OcrService.java b/src/main/java/app/mealsmadeeasy/api/ai/OcrService.java new file mode 100644 index 0000000..54a6a59 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/ai/OcrService.java @@ -0,0 +1,33 @@ +package app.mealsmadeeasy.api.ai; + +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.ollama.api.OllamaChatOptions; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OcrService { + + private final ChatModel chatModel; + + public @Nullable String readMarkdown(Media inputMedia) { + final Message ocrMessage = UserMessage.builder() + .text("Convert the text in the given file to Markdown.") + .media(inputMedia) + .build(); + final Prompt ocrPrompt = Prompt.builder() + .messages(ocrMessage) + .chatOptions(OllamaChatOptions.builder().model("deepseek-ocr:latest").build()) + .build(); + final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt); + return ocrResponse.getResult().getOutput().getText(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/inference/InferenceController.java b/src/main/java/app/mealsmadeeasy/api/inference/InferenceController.java deleted file mode 100644 index 471aa68..0000000 --- a/src/main/java/app/mealsmadeeasy/api/inference/InferenceController.java +++ /dev/null @@ -1,37 +0,0 @@ -package app.mealsmadeeasy.api.inference; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.codec.ServerSentEvent; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; -import reactor.core.publisher.Flux; - -import java.io.IOException; -import java.util.Map; - -@RestController -@RequestMapping("/inferences") -public class InferenceController { - - private final InferenceService inferenceService; - - public InferenceController(InferenceService inferenceService) { - this.inferenceService = inferenceService; - } - - @PutMapping("/recipe-extract") - public ResponseEntity recipeExtract(@RequestParam MultipartFile recipeImageFile) throws IOException { - return ResponseEntity.ok(this.inferenceService.extractRecipe(recipeImageFile.getInputStream())); - } - - @PutMapping(value = "/recipe-extract-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public Flux>> recipeExtractStream(@RequestParam MultipartFile recipeImageFile) throws IOException { - return this.inferenceService.extractRecipeStream(recipeImageFile.getInputStream()) - .map(data -> ServerSentEvent.builder(Map.of("delta", data)).build()); - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/inference/InferenceService.java b/src/main/java/app/mealsmadeeasy/api/inference/InferenceService.java deleted file mode 100644 index 8b9973e..0000000 --- a/src/main/java/app/mealsmadeeasy/api/inference/InferenceService.java +++ /dev/null @@ -1,52 +0,0 @@ -package app.mealsmadeeasy.api.inference; - -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.content.Media; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.InputStreamResource; -import org.springframework.stereotype.Service; -import org.springframework.util.MimeType; -import reactor.core.publisher.Flux; - -import java.io.InputStream; - -@Service -public class InferenceService { - - private final ChatClient chatClient; - - public InferenceService(ChatClient.Builder chatClientBuilder) { - this.chatClient = chatClientBuilder.build(); - } - - public String extractRecipe(InputStream recipeImageInputStream) { - final Media media = Media.builder() - .data(new InputStreamResource(recipeImageInputStream)) - .mimeType(MimeType.valueOf("image/jpeg")) - .build(); - - final String markdownResponse = this.chatClient.prompt() - .user(u -> - u.text(new ClassPathResource("app/mealsmadeeasy/api/inference/recipe-extract-user-prompt.md")) - .media(media) - ) - .call() - .content(); - return markdownResponse; - } - - public Flux extractRecipeStream(InputStream recipeImageInputStream) { - final Media media = Media.builder() - .data(new InputStreamResource(recipeImageInputStream)) - .mimeType(MimeType.valueOf("image/jpeg")) - .build(); - - return this.chatClient.prompt() - .user(u -> - u.text(new ClassPathResource("app/mealsmadeeasy/api/inference/recipe-extract-user-prompt.md")) - .media(media) - ) - .stream().content(); - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/inference/RecipeExtractionEntity.java b/src/main/java/app/mealsmadeeasy/api/inference/RecipeExtractionEntity.java deleted file mode 100644 index 5eb4f46..0000000 --- a/src/main/java/app/mealsmadeeasy/api/inference/RecipeExtractionEntity.java +++ /dev/null @@ -1,36 +0,0 @@ -package app.mealsmadeeasy.api.inference; - -import jakarta.persistence.*; - -import java.util.UUID; - -@Entity -@Table(name = "recipe_extraction") -public class RecipeExtractionEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - - @Lob - @Column(columnDefinition = "TEXT") - @Basic(fetch = FetchType.LAZY) - private String markdown; - - public UUID getId() { - return this.id; - } - - public void setId(UUID id) { - this.id = id; - } - - public String getMarkdown() { - return this.markdown; - } - - public void setMarkdown(String markdown) { - this.markdown = markdown; - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/inference/RecipeExtractionView.java b/src/main/java/app/mealsmadeeasy/api/inference/RecipeExtractionView.java deleted file mode 100644 index 3f47bbc..0000000 --- a/src/main/java/app/mealsmadeeasy/api/inference/RecipeExtractionView.java +++ /dev/null @@ -1,33 +0,0 @@ -package app.mealsmadeeasy.api.inference; - -import java.util.UUID; - -public class RecipeExtractionView { - - public static RecipeExtractionView from(RecipeExtractionEntity entity) { - final var view = new RecipeExtractionView(); - view.setId(entity.getId()); - view.setMarkdown(entity.getMarkdown()); - return view; - } - - private UUID id; - private String markdown; - - public UUID getId() { - return this.id; - } - - public void setId(UUID id) { - this.id = id; - } - - public String getMarkdown() { - return this.markdown; - } - - public void setMarkdown(String markdown) { - this.markdown = markdown; - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java index d2421e4..90eb63f 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java @@ -82,12 +82,12 @@ public class Recipe { private RecipeEmbedding embedding; @PrePersist - public void prePersist() { + private void prePersist() { this.created = OffsetDateTime.now(); } @PreUpdate - public void preUpdate() { + private void preUpdate() { this.modified = OffsetDateTime.now(); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java index 17e697d..efcfff3 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java @@ -71,4 +71,14 @@ public class RecipeDraft { @Column(columnDefinition = "jsonb") private @Nullable List inferences; + @PrePersist + private void prePersist() { + this.created = OffsetDateTime.now(); + } + + @PreUpdate + private void preUpdate() { + this.modified = OffsetDateTime.now(); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java index 0483033..87f2cc7 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java @@ -63,7 +63,7 @@ public class RecipeDraftsController { throw new MustBeLoggedInException(); } final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); - return ResponseEntity.ok(this.draftToViewConverter.convert(recipeDraft, owner)); + return ResponseEntity.status(HttpStatus.CREATED).body(this.draftToViewConverter.convert(recipeDraft, owner)); } @PostMapping("/ai") diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index ae225c3..bef4bba 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -46,7 +46,6 @@ public class RecipeService { public Recipe create(User owner, RecipeCreateSpec spec) { final Recipe draft = new Recipe(); - draft.setCreated(OffsetDateTime.now()); draft.setOwner(owner); draft.setSlug(spec.getSlug()); draft.setTitle(spec.getTitle()); @@ -125,33 +124,26 @@ public class RecipeService { } private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) { - boolean didUpdate = false; if (spec.getTitle() != null) { recipe.setTitle(spec.getTitle()); - didUpdate = true; } if (spec.getPreparationTime() != null) { recipe.setPreparationTime(spec.getPreparationTime()); - didUpdate = true; } if (spec.getCookingTime() != null) { recipe.setCookingTime(spec.getCookingTime()); - didUpdate = true; } if (spec.getTotalTime() != null) { recipe.setTotalTime(spec.getTotalTime()); - didUpdate = true; } if (spec.getRawText() != null) { recipe.setRawText(spec.getRawText()); recipe.setCachedRenderedText(null); - didUpdate = true; } if (spec.getIsPublic() != null) { recipe.setIsPublic(spec.getIsPublic()); - didUpdate = true; } // TODO: we have to think about how to unset the main image vs. just leaving it out of the request @@ -163,10 +155,6 @@ public class RecipeService { ); recipe.setMainImage(mainImage); } - - if (didUpdate) { - recipe.setModified(OffsetDateTime.now()); - } } @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") @@ -233,7 +221,6 @@ public class RecipeService { public RecipeDraft createDraft(User owner) { final var recipeDraft = new RecipeDraft(); recipeDraft.setState(RecipeDraft.State.ENTER_DATA); - recipeDraft.setCreated(OffsetDateTime.now()); recipeDraft.setOwner(owner); return this.recipeDraftRepository.save(recipeDraft); } @@ -242,7 +229,6 @@ public class RecipeService { public RecipeDraft createAiDraft(File sourceFile, User owner) { final var recipeDraft = new RecipeDraft(); recipeDraft.setState(RecipeDraft.State.INFER); - recipeDraft.setCreated(OffsetDateTime.now()); recipeDraft.setOwner(owner); final var saved = this.recipeDraftRepository.save(recipeDraft); @@ -311,7 +297,6 @@ public class RecipeService { if (spec.mainImage() != null) { recipeDraft.setMainImage(spec.mainImage()); } - recipeDraft.setModified(OffsetDateTime.now()); return this.recipeDraftRepository.save(recipeDraft); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java b/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java index 36af88c..e0390bc 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java @@ -1,10 +1,12 @@ package app.mealsmadeeasy.api.recipe.body; import app.mealsmadeeasy.api.util.SetImageBody; +import lombok.Builder; import org.jetbrains.annotations.Nullable; import java.util.List; +@Builder public record RecipeDraftUpdateBody( @Nullable String slug, @Nullable String title, @@ -16,6 +18,7 @@ public record RecipeDraftUpdateBody( @Nullable SetImageBody mainImage ) { + @Builder public record IngredientDraftUpdateBody( @Nullable String amount, String name, diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java index 431db10..d95774e 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java @@ -25,6 +25,7 @@ public class RecipeDraftToViewConverter { .created(recipeDraft.getCreated()) .modified(recipeDraft.getModified()) .state(recipeDraft.getState()) + .title(recipeDraft.getTitle()) .slug(recipeDraft.getSlug()) .preparationTime(recipeDraft.getPreparationTime()) .cookingTime(recipeDraft.getCookingTime()) diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java index 861a076..de45eac 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java @@ -1,5 +1,7 @@ package app.mealsmadeeasy.api.recipe.job; +import app.mealsmadeeasy.api.ai.InferenceService; +import app.mealsmadeeasy.api.ai.OcrService; import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.file.FileService; import app.mealsmadeeasy.api.job.Job; @@ -14,13 +16,8 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.ChatOptions; -import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.content.Media; import org.springframework.ai.converter.BeanOutputConverter; -import org.springframework.ai.ollama.api.OllamaChatOptions; import org.springframework.core.io.InputStreamResource; import org.springframework.stereotype.Component; import org.springframework.util.MimeType; @@ -52,7 +49,8 @@ public class RecipeInferJobHandler implements JobHandler private final FileService fileService; private final RecipeService recipeService; - private final ChatModel chatModel; + private final OcrService ocrService; + private final InferenceService inferenceService; private final BeanOutputConverter extractionConverter = new BeanOutputConverter<>(RecipeExtraction.class); @@ -82,17 +80,7 @@ public class RecipeInferJobHandler implements JobHandler .data(new InputStreamResource(sourceFileContent)) .mimeType(MimeType.valueOf(sourceFile.getMimeType())) .build(); - - final Message ocrMessage = UserMessage.builder() - .text("Convert the recipe in the given file to Markdown.") - .media(sourceFileMedia) - .build(); - final Prompt ocrPrompt = Prompt.builder() - .messages(ocrMessage) - .chatOptions(OllamaChatOptions.builder().model("deepseek-ocr:latest").build()) - .build(); - final ChatResponse ocrResponse = this.chatModel.call(ocrPrompt); - final String fullMarkdownText = ocrResponse.getResult().getOutput().getText(); + final @Nullable String fullMarkdownText = this.ocrService.readMarkdown(sourceFileMedia); if (fullMarkdownText == null) { throw new RuntimeException("fullMarkdownText from ocr came back null"); } @@ -132,22 +120,17 @@ public class RecipeInferJobHandler implements JobHandler final Message extractUserMessage = UserMessage.builder() .text(fullMarkdownText) .build(); - final ChatOptions extractChatOptions = OllamaChatOptions.builder() - .model("gemma3:latest") - .format(extractionConverter.getJsonSchemaMap()) - .build(); - final Prompt extractPrompt = Prompt.builder() - .messages(extractSystemMessage, extractUserMessage) - .chatOptions(extractChatOptions) - .build(); - final ChatResponse extractResponse = this.chatModel.call(extractPrompt); - final String extractContent = extractResponse.getResult().getOutput().getText(); - if (extractContent == null) { - throw new RuntimeException("extractContent from extract came back null"); - } - logger.debug("extract returned:\n{}", extractContent); - final RecipeExtraction extraction = this.extractionConverter.convert(extractContent); + final @Nullable RecipeExtraction extraction = this.inferenceService.extract( + extractSystemMessage, + extractUserMessage, + this.extractionConverter + ); + if (extraction == null) { + throw new RuntimeException("extraction returned null"); + } + + logger.debug("extract returned:\n{}", extraction); // 3. Get draft and set props from extraction final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId());