Some various refactoring. Added more testing for recipe drafts controller.

This commit is contained in:
Jesse Brault 2026-01-23 17:51:00 -06:00
parent 0a83a032c8
commit 54118d597e
15 changed files with 237 additions and 209 deletions

View File

@ -1,23 +1,36 @@
package app.mealsmadeeasy.api.recipe; package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.IntegrationTestsExtension; 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.AuthService;
import app.mealsmadeeasy.api.auth.LoginException; 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.User;
import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; 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.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.MimeTypeUtils;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.hasSize; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -38,6 +51,9 @@ public class RecipeDraftsControllerIntegrationTests {
@Autowired @Autowired
private RecipeService recipeService; private RecipeService recipeService;
@Autowired
private ObjectMapper objectMapper;
private static final String TEST_PASSWORD = "test"; private static final String TEST_PASSWORD = "test";
private User seedUser() { private User seedUser() {
@ -114,4 +130,118 @@ public class RecipeDraftsControllerIntegrationTests {
.andExpect(jsonPath("$", hasSize(2))); .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")));
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -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 <T> @Nullable T extract(
Message systemMessage,
Message userMessage,
BeanOutputConverter<T> 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);
}
}

View File

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

View File

@ -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<String> 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<ServerSentEvent<Map<String, String>>> recipeExtractStream(@RequestParam MultipartFile recipeImageFile) throws IOException {
return this.inferenceService.extractRecipeStream(recipeImageFile.getInputStream())
.map(data -> ServerSentEvent.builder(Map.of("delta", data)).build());
}
}

View File

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

View File

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

View File

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

View File

@ -82,12 +82,12 @@ public class Recipe {
private RecipeEmbedding embedding; private RecipeEmbedding embedding;
@PrePersist @PrePersist
public void prePersist() { private void prePersist() {
this.created = OffsetDateTime.now(); this.created = OffsetDateTime.now();
} }
@PreUpdate @PreUpdate
public void preUpdate() { private void preUpdate() {
this.modified = OffsetDateTime.now(); this.modified = OffsetDateTime.now();
} }

View File

@ -71,4 +71,14 @@ public class RecipeDraft {
@Column(columnDefinition = "jsonb") @Column(columnDefinition = "jsonb")
private @Nullable List<RecipeDraftInference> inferences; private @Nullable List<RecipeDraftInference> inferences;
@PrePersist
private void prePersist() {
this.created = OffsetDateTime.now();
}
@PreUpdate
private void preUpdate() {
this.modified = OffsetDateTime.now();
}
} }

View File

@ -63,7 +63,7 @@ public class RecipeDraftsController {
throw new MustBeLoggedInException(); throw new MustBeLoggedInException();
} }
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); 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") @PostMapping("/ai")

View File

@ -46,7 +46,6 @@ public class RecipeService {
public Recipe create(User owner, RecipeCreateSpec spec) { public Recipe create(User owner, RecipeCreateSpec spec) {
final Recipe draft = new Recipe(); final Recipe draft = new Recipe();
draft.setCreated(OffsetDateTime.now());
draft.setOwner(owner); draft.setOwner(owner);
draft.setSlug(spec.getSlug()); draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle()); draft.setTitle(spec.getTitle());
@ -125,33 +124,26 @@ public class RecipeService {
} }
private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) { private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) {
boolean didUpdate = false;
if (spec.getTitle() != null) { if (spec.getTitle() != null) {
recipe.setTitle(spec.getTitle()); recipe.setTitle(spec.getTitle());
didUpdate = true;
} }
if (spec.getPreparationTime() != null) { if (spec.getPreparationTime() != null) {
recipe.setPreparationTime(spec.getPreparationTime()); recipe.setPreparationTime(spec.getPreparationTime());
didUpdate = true;
} }
if (spec.getCookingTime() != null) { if (spec.getCookingTime() != null) {
recipe.setCookingTime(spec.getCookingTime()); recipe.setCookingTime(spec.getCookingTime());
didUpdate = true;
} }
if (spec.getTotalTime() != null) { if (spec.getTotalTime() != null) {
recipe.setTotalTime(spec.getTotalTime()); recipe.setTotalTime(spec.getTotalTime());
didUpdate = true;
} }
if (spec.getRawText() != null) { if (spec.getRawText() != null) {
recipe.setRawText(spec.getRawText()); recipe.setRawText(spec.getRawText());
recipe.setCachedRenderedText(null); recipe.setCachedRenderedText(null);
didUpdate = true;
} }
if (spec.getIsPublic() != null) { if (spec.getIsPublic() != null) {
recipe.setIsPublic(spec.getIsPublic()); 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 // 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); recipe.setMainImage(mainImage);
} }
if (didUpdate) {
recipe.setModified(OffsetDateTime.now());
}
} }
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
@ -233,7 +221,6 @@ public class RecipeService {
public RecipeDraft createDraft(User owner) { public RecipeDraft createDraft(User owner) {
final var recipeDraft = new RecipeDraft(); final var recipeDraft = new RecipeDraft();
recipeDraft.setState(RecipeDraft.State.ENTER_DATA); recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setOwner(owner); recipeDraft.setOwner(owner);
return this.recipeDraftRepository.save(recipeDraft); return this.recipeDraftRepository.save(recipeDraft);
} }
@ -242,7 +229,6 @@ public class RecipeService {
public RecipeDraft createAiDraft(File sourceFile, User owner) { public RecipeDraft createAiDraft(File sourceFile, User owner) {
final var recipeDraft = new RecipeDraft(); final var recipeDraft = new RecipeDraft();
recipeDraft.setState(RecipeDraft.State.INFER); recipeDraft.setState(RecipeDraft.State.INFER);
recipeDraft.setCreated(OffsetDateTime.now());
recipeDraft.setOwner(owner); recipeDraft.setOwner(owner);
final var saved = this.recipeDraftRepository.save(recipeDraft); final var saved = this.recipeDraftRepository.save(recipeDraft);
@ -311,7 +297,6 @@ public class RecipeService {
if (spec.mainImage() != null) { if (spec.mainImage() != null) {
recipeDraft.setMainImage(spec.mainImage()); recipeDraft.setMainImage(spec.mainImage());
} }
recipeDraft.setModified(OffsetDateTime.now());
return this.recipeDraftRepository.save(recipeDraft); return this.recipeDraftRepository.save(recipeDraft);
} }

View File

@ -1,10 +1,12 @@
package app.mealsmadeeasy.api.recipe.body; package app.mealsmadeeasy.api.recipe.body;
import app.mealsmadeeasy.api.util.SetImageBody; import app.mealsmadeeasy.api.util.SetImageBody;
import lombok.Builder;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
@Builder
public record RecipeDraftUpdateBody( public record RecipeDraftUpdateBody(
@Nullable String slug, @Nullable String slug,
@Nullable String title, @Nullable String title,
@ -16,6 +18,7 @@ public record RecipeDraftUpdateBody(
@Nullable SetImageBody mainImage @Nullable SetImageBody mainImage
) { ) {
@Builder
public record IngredientDraftUpdateBody( public record IngredientDraftUpdateBody(
@Nullable String amount, @Nullable String amount,
String name, String name,

View File

@ -25,6 +25,7 @@ public class RecipeDraftToViewConverter {
.created(recipeDraft.getCreated()) .created(recipeDraft.getCreated())
.modified(recipeDraft.getModified()) .modified(recipeDraft.getModified())
.state(recipeDraft.getState()) .state(recipeDraft.getState())
.title(recipeDraft.getTitle())
.slug(recipeDraft.getSlug()) .slug(recipeDraft.getSlug())
.preparationTime(recipeDraft.getPreparationTime()) .preparationTime(recipeDraft.getPreparationTime())
.cookingTime(recipeDraft.getCookingTime()) .cookingTime(recipeDraft.getCookingTime())

View File

@ -1,5 +1,7 @@
package app.mealsmadeeasy.api.recipe.job; 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.File;
import app.mealsmadeeasy.api.file.FileService; import app.mealsmadeeasy.api.file.FileService;
import app.mealsmadeeasy.api.job.Job; 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.Message;
import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage; 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.content.Media;
import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.MimeType; import org.springframework.util.MimeType;
@ -52,7 +49,8 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
private final FileService fileService; private final FileService fileService;
private final RecipeService recipeService; private final RecipeService recipeService;
private final ChatModel chatModel; private final OcrService ocrService;
private final InferenceService inferenceService;
private final BeanOutputConverter<RecipeExtraction> extractionConverter = private final BeanOutputConverter<RecipeExtraction> extractionConverter =
new BeanOutputConverter<>(RecipeExtraction.class); new BeanOutputConverter<>(RecipeExtraction.class);
@ -82,17 +80,7 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
.data(new InputStreamResource(sourceFileContent)) .data(new InputStreamResource(sourceFileContent))
.mimeType(MimeType.valueOf(sourceFile.getMimeType())) .mimeType(MimeType.valueOf(sourceFile.getMimeType()))
.build(); .build();
final @Nullable String fullMarkdownText = this.ocrService.readMarkdown(sourceFileMedia);
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();
if (fullMarkdownText == null) { if (fullMarkdownText == null) {
throw new RuntimeException("fullMarkdownText from ocr came back null"); throw new RuntimeException("fullMarkdownText from ocr came back null");
} }
@ -132,22 +120,17 @@ public class RecipeInferJobHandler implements JobHandler<RecipeInferJobPayload>
final Message extractUserMessage = UserMessage.builder() final Message extractUserMessage = UserMessage.builder()
.text(fullMarkdownText) .text(fullMarkdownText)
.build(); .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 // 3. Get draft and set props from extraction
final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId()); final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId());