Some various refactoring. Added more testing for recipe drafts controller.
This commit is contained in:
parent
0a83a032c8
commit
54118d597e
@ -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 |
41
src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java
Normal file
41
src/main/java/app/mealsmadeeasy/api/ai/InferenceService.java
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
33
src/main/java/app/mealsmadeeasy/api/ai/OcrService.java
Normal file
33
src/main/java/app/mealsmadeeasy/api/ai/OcrService.java
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user