diff --git a/build.gradle b/build.gradle index ddc6399..d815771 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,9 @@ dependencies { implementation 'org.jsoup:jsoup:1.21.2' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1' + // Source: https://mvnrepository.com/artifact/io.hypersistence/hypersistence-utils-hibernate-63 + implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.14.1' + implementation 'io.minio:minio:8.6.0' compileOnly 'org.jetbrains:annotations:26.0.2-1' @@ -98,6 +101,10 @@ dependencies { testImplementation 'org.testcontainers:junit-jupiter:1.21.4' testImplementation 'org.testcontainers:postgresql:1.21.4' testImplementation 'org.testcontainers:minio:1.21.4' + testImplementation 'org.testcontainers:testcontainers-ollama:2.0.3' + + // Source: https://mvnrepository.com/artifact/org.awaitility/awaitility + testImplementation("org.awaitility:awaitility:4.3.0") testFixturesImplementation 'org.hamcrest:hamcrest:3.0' } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/job/JobServiceIntegrationTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/job/JobServiceIntegrationTests.java new file mode 100644 index 0000000..973e5a8 --- /dev/null +++ b/src/integrationTest/java/app/mealsmadeeasy/api/job/JobServiceIntegrationTests.java @@ -0,0 +1,75 @@ +package app.mealsmadeeasy.api.job; + +import app.mealsmadeeasy.api.IntegrationTestsExtension; +import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler; +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.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; + +@SpringBootTest +@ExtendWith(IntegrationTestsExtension.class) +public class JobServiceIntegrationTests { + + @Component + private static final class TestJobHandler implements JobHandler { + + @Override + public Class getPayloadType() { + return Object.class; + } + + @Override + public String getJobKey() { + return "TEST_JOB_TYPE"; + } + + @Override + public void handle(Job job, Object payload) { + // no-op + } + + } + + @TestConfiguration + public static class JobServiceIntegrationTestsConfiguration { + + @Bean + public JobHandler testJobHandler() { + return new TestJobHandler(); + } + + } + + // If not mockito, it will try to load a ChatClient + @MockitoBean + private RecipeInferJobHandler recipeInferJobHandler; + + @Autowired + private JobService jobService; + + @Autowired + private JobRepository jobRepository; + + @Test + public void smokeScreen() { + final Job job = this.jobService.create("TEST_JOB_TYPE", null); + await().atMost(1, TimeUnit.SECONDS).until(() -> { + final Job foundJob = this.jobRepository.findById(job.getId()).orElse(null); + if (foundJob == null) { + return false; + } else { + return foundJob.getState().equals(Job.State.DONE); + } + }); + } + +} diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java index 7c5cbed..c244a15 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java @@ -24,8 +24,7 @@ import java.util.UUID; import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes; import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -342,4 +341,13 @@ public class RecipeServiceTests { assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner)); } + @Test + public void createDraftReturnsDefaultRecipeDraft() { + final User owner = this.seedUser(); + final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); + assertThat(recipeDraft.getCreated(), is(notNullValue())); + assertThat(recipeDraft.getState(), is(RecipeDraft.State.ENTER_DATA)); + assertThat(recipeDraft.getOwner(), is(owner)); + } + } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobIntegrationTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobIntegrationTests.java new file mode 100644 index 0000000..d7da271 --- /dev/null +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobIntegrationTests.java @@ -0,0 +1,88 @@ +package app.mealsmadeeasy.api.recipe.job; + +import app.mealsmadeeasy.api.IntegrationTestsExtension; +import app.mealsmadeeasy.api.file.File; +import app.mealsmadeeasy.api.file.FileService; +import app.mealsmadeeasy.api.recipe.RecipeDraft; +import app.mealsmadeeasy.api.recipe.RecipeService; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.UserCreateException; +import app.mealsmadeeasy.api.user.UserService; +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.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +@SpringBootTest +@ExtendWith(IntegrationTestsExtension.class) +@Testcontainers +public class RecipeInferJobIntegrationTests { + + @Container + private static final MinIOContainer minioContainer = new MinIOContainer("minio/minio:latest"); + + @DynamicPropertySource + public static void minioProperties(DynamicPropertyRegistry registry) { + registry.add("app.mealsmadeeasy.api.minio.endpoint", minioContainer::getS3URL); + registry.add("app.mealsmadeeasy.api.minio.accessKey", minioContainer::getUserName); + registry.add("app.mealsmadeeasy.api.minio.secretKey", minioContainer::getPassword); + } + + @Autowired + private UserService userService; + + @Autowired + private RecipeService recipeService; + + @Autowired + private FileService fileService; + + @Test + public void enqueueJobAndWait() throws UserCreateException, IOException { + final String ownerName = UUID.randomUUID().toString(); + final User owner = this.userService.createUser( + ownerName, + ownerName + "@test.com", + "test-pass" + ); + final File sourceFile = this.fileService.create( + RecipeInferJobIntegrationTests.class.getResourceAsStream("recipe.jpeg"), + "recipe.jpeg", + 127673L, + owner + ); + final RecipeDraft draft = this.recipeService.createAiDraft(sourceFile, owner); + + await().atMost(60, TimeUnit.SECONDS).until(() -> { + final RecipeDraft foundDraft = this.recipeService.getDraftById(draft.getId()); + return foundDraft.getState().equals(RecipeDraft.State.ENTER_DATA); + }); + + final RecipeDraft draftWithInference = this.recipeService.getDraftById(draft.getId()); + + assertThat(draftWithInference.getInferences(), is(notNullValue())); + final List inferences = draftWithInference.getInferences(); + + assertThat(inferences.size(), is(1)); + final RecipeDraft.RecipeDraftInference inference = inferences.getFirst(); + assertThat(inference.getTitle(), is(notNullValue())); + assertThat(inference.getRawText(), is(notNullValue())); + assertThat(inference.getInferredAt(), is(notNullValue())); + } + +} diff --git a/src/integrationTest/resources/app/mealsmadeeasy/api/recipe/job/recipe.jpeg b/src/integrationTest/resources/app/mealsmadeeasy/api/recipe/job/recipe.jpeg new file mode 100644 index 0000000..fa6b65f Binary files /dev/null and b/src/integrationTest/resources/app/mealsmadeeasy/api/recipe/job/recipe.jpeg differ diff --git a/src/integrationTest/resources/application.properties b/src/integrationTest/resources/application.properties index 64e0fc8..283333e 100644 --- a/src/integrationTest/resources/application.properties +++ b/src/integrationTest/resources/application.properties @@ -5,6 +5,7 @@ app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000 app.mealsmadeeasy.api.minio.accessKey=minio-root app.mealsmadeeasy.api.minio.secretKey=test0123 app.mealsmadeeasy.api.images.bucketName=images +app.mealsmadeeasy.api.files.bucketName=files # Source - https://stackoverflow.com/questions/3164072/large-objects-may-not-be-used-in-auto-commit-mode # Posted by Iogui, modified by community. See post 'Timeline' for change history diff --git a/src/main/java/app/mealsmadeeasy/api/file/File.java b/src/main/java/app/mealsmadeeasy/api/file/File.java new file mode 100644 index 0000000..3d2a1ca --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/file/File.java @@ -0,0 +1,36 @@ +package app.mealsmadeeasy.api.file; + +import app.mealsmadeeasy.api.user.User; +import jakarta.persistence.*; +import lombok.Data; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "file") +@Data +public class File { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, updatable = false) + private UUID id; + + @Column(nullable = false, updatable = false) + private OffsetDateTime created; + + @Column(nullable = false) + private String userFilename; + + @Column(nullable = false, updatable = false) + private String mimeType; + + @Column(nullable = false, updatable = false) + private String objectName; + + @ManyToOne(optional = false) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; + +} diff --git a/src/main/java/app/mealsmadeeasy/api/file/FileRepository.java b/src/main/java/app/mealsmadeeasy/api/file/FileRepository.java new file mode 100644 index 0000000..8890a1b --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/file/FileRepository.java @@ -0,0 +1,7 @@ +package app.mealsmadeeasy.api.file; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface FileRepository extends JpaRepository {} diff --git a/src/main/java/app/mealsmadeeasy/api/file/FileService.java b/src/main/java/app/mealsmadeeasy/api/file/FileService.java new file mode 100644 index 0000000..fbb7cb8 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/file/FileService.java @@ -0,0 +1,75 @@ +package app.mealsmadeeasy.api.file; + +import app.mealsmadeeasy.api.s3.S3Manager; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.util.MimeTypeService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Service +public class FileService { + + private final FileRepository fileRepository; + private final S3Manager s3Manager; + private final MimeTypeService mimeTypeService; + private final String filesBucket; + + public FileService( + FileRepository fileRepository, + S3Manager s3Manager, + MimeTypeService mimeTypeService, + @Value("${app.mealsmadeeasy.api.files.bucketName}") String filesBucket + ) { + this.fileRepository = fileRepository; + this.s3Manager = s3Manager; + this.mimeTypeService = mimeTypeService; + this.filesBucket = filesBucket; + } + + public File create( + InputStream fileContent, + String userFilename, + long fileSize, + User owner + ) throws IOException { + final UUID uuid = UUID.randomUUID(); + final String mimeType = this.mimeTypeService.getMimeType(userFilename); + final String filename = uuid + "." + this.mimeTypeService.getExtension(mimeType); + + final String objectName = this.s3Manager.store( + this.filesBucket, + filename, + mimeType, + fileContent, + fileSize + ); + + final var file = new File(); + file.setCreated(OffsetDateTime.now()); + file.setUserFilename(userFilename); + file.setMimeType(mimeType); + file.setObjectName(objectName); + file.setOwner(owner); + + return this.fileRepository.save(file); + } + + public InputStream getFileContentById(UUID fileId) throws IOException { + final File file = this.fileRepository.findById(fileId).orElseThrow(() -> new IllegalArgumentException( + "File with id " + fileId + " not found" + )); + return this.s3Manager.load(this.filesBucket, file.getObjectName()); + } + + public File getById(UUID id) { + return this.fileRepository.findById(id).orElseThrow(() -> new IllegalArgumentException( + "File with id " + id + " not found" + )); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index 60cc6c9..a7a6fd5 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -5,6 +5,7 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.s3.S3Manager; import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.util.MimeTypeService; import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PostAuthorize; @@ -19,63 +20,28 @@ import java.io.IOException; import java.io.InputStream; import java.time.OffsetDateTime; import java.util.*; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Service public class S3ImageService implements ImageService { - private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$"); - - private static final String IMAGE_JPEG = "image/jpeg"; - private static final String IMAGE_PNG = "image/png"; - private static final String IMAGE_SVG = "image/svg+xml"; - private static final String IMAGE_WEBP = "image/webp"; - private final S3Manager s3Manager; private final ImageRepository imageRepository; private final String imageBucketName; private final String baseUrl; + private final MimeTypeService mimeTypeService; public S3ImageService( S3Manager s3Manager, ImageRepository imageRepository, @Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName, - @Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl + @Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl, + MimeTypeService mimeTypeService ) { this.s3Manager = s3Manager; this.imageRepository = imageRepository; this.imageBucketName = imageBucketName; this.baseUrl = baseUrl; - } - - private String getMimeType(String userFilename) { - final Matcher m = extensionPattern.matcher(userFilename); - if (m.matches()) { - final String extension = m.group(1); - return switch (extension) { - case "jpg", "jpeg" -> IMAGE_JPEG; - case "png" -> IMAGE_PNG; - case "svg" -> IMAGE_SVG; - case "webp" -> IMAGE_WEBP; - default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension); - }; - } else { - throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename); - } - } - - private String getExtension(String mimeType) throws ImageException { - return switch (mimeType) { - case IMAGE_JPEG -> "jpg"; - case IMAGE_PNG -> "png"; - case IMAGE_SVG -> "svg"; - case IMAGE_WEBP -> "webp"; - default -> throw new ImageException( - ImageException.Type.UNSUPPORTED_IMAGE_TYPE, - "Unsupported mime type: " + mimeType - ); - }; + this.mimeTypeService = mimeTypeService; } private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) { @@ -157,9 +123,9 @@ public class S3ImageService implements ImageService { long objectSize, ImageCreateSpec createSpec ) throws IOException, ImageException { - final String mimeType = this.getMimeType(userFilename); + final String mimeType = this.mimeTypeService.getMimeType(userFilename); final String uuid = UUID.randomUUID().toString(); - final String extension = this.getExtension(mimeType); + final String extension = this.mimeTypeService.getExtension(mimeType); final String filename = uuid + "." + extension; final var baos = new ByteArrayOutputStream(); diff --git a/src/main/java/app/mealsmadeeasy/api/job/Job.java b/src/main/java/app/mealsmadeeasy/api/job/Job.java new file mode 100644 index 0000000..5a672fa --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/job/Job.java @@ -0,0 +1,60 @@ +package app.mealsmadeeasy.api.job; + +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.Type; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "job") +@Data +public class Job { + + public enum State { + QUEUED, RUNNING, DONE, FAILED, DEAD + } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, updatable = false) + private UUID id; + + @Column(nullable = false, updatable = false) + private OffsetDateTime created; + + private OffsetDateTime modified; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private State state; + + @Column(nullable = false) + private String jobKey; + + @Type(JsonBinaryType.class) + @Column(nullable = false, columnDefinition = "jsonb") + private JsonNode payload; + + @Column(nullable = false) + private Integer attempts; + + @Column(nullable = false) + private Integer maxAttempts; + + @Column(nullable = false) + private OffsetDateTime runAfter; + + private String lockedBy; + + private OffsetDateTime lockedAt; + + @Lob + @Column(columnDefinition = "TEXT") + @Basic(fetch = FetchType.LAZY) + private String lastError; + +} diff --git a/src/main/java/app/mealsmadeeasy/api/job/JobEntity.java b/src/main/java/app/mealsmadeeasy/api/job/JobEntity.java deleted file mode 100644 index 0a7b469..0000000 --- a/src/main/java/app/mealsmadeeasy/api/job/JobEntity.java +++ /dev/null @@ -1,21 +0,0 @@ -package app.mealsmadeeasy.api.job; - -import jakarta.persistence.Column; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; - -public class JobEntity { - - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(nullable = false, updatable = false) - private Long id; - - @Column(nullable = false) - private String key; - - @Column(nullable = false, columnDefinition = "jsonb") - private String payload; - -} diff --git a/src/main/java/app/mealsmadeeasy/api/job/JobHandler.java b/src/main/java/app/mealsmadeeasy/api/job/JobHandler.java new file mode 100644 index 0000000..41bc1e5 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/job/JobHandler.java @@ -0,0 +1,7 @@ +package app.mealsmadeeasy.api.job; + +public interface JobHandler { + Class getPayloadType(); + String getJobKey(); + void handle(Job job, T payload); +} diff --git a/src/main/java/app/mealsmadeeasy/api/job/JobRepository.java b/src/main/java/app/mealsmadeeasy/api/job/JobRepository.java new file mode 100644 index 0000000..e6f8a2f --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/job/JobRepository.java @@ -0,0 +1,27 @@ +package app.mealsmadeeasy.api.job; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; +import java.util.UUID; + +public interface JobRepository extends JpaRepository { + + @Query(value = """ + WITH cte AS ( + SELECT id FROM job WHERE state = 'QUEUED' AND run_after <= now() ORDER BY run_after, created + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE job j + SET state = 'RUNNING', + locked_by = ?1, + locked_at = now(), + modified = now() + FROM cte + WHERE j.id = cte.id + RETURNING j.* + """, nativeQuery = true) + Optional claimNext(String lockedBy); + +} diff --git a/src/main/java/app/mealsmadeeasy/api/job/JobService.java b/src/main/java/app/mealsmadeeasy/api/job/JobService.java new file mode 100644 index 0000000..f3a07c9 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/job/JobService.java @@ -0,0 +1,119 @@ +package app.mealsmadeeasy.api.job; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.transaction.Transactional; +import org.jetbrains.annotations.Nullable; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +@Service +public class JobService { + + private final ApplicationContext applicationContext; + private final JobRepository jobRepository; + private final ObjectMapper objectMapper; + + @SuppressWarnings("rawtypes") + private Map jobHandlers; + + public JobService(ApplicationContext applicationContext, JobRepository jobRepository, ObjectMapper objectMapper) { + this.applicationContext = applicationContext; + this.jobRepository = jobRepository; + this.objectMapper = objectMapper; + } + + public Job create(String jobType, @Nullable Object payload) { + return this.create(jobType, payload, 10, OffsetDateTime.now()); + } + + public Job create(String jobType, @Nullable Object payload, int maxAttempts, OffsetDateTime runAfter) { + final var job = new Job(); + job.setCreated(OffsetDateTime.now()); + job.setState(Job.State.QUEUED); + job.setJobKey(jobType); + job.setPayload(this.objectMapper.convertValue(payload, JsonNode.class)); + job.setAttempts(0); + job.setMaxAttempts(maxAttempts); + job.setRunAfter(runAfter); + return this.jobRepository.save(job); + } + + @SuppressWarnings("rawtypes") + private void initJobHandlers() { + final Map handlersByBeanName = this.applicationContext.getBeansOfType(JobHandler.class); + this.jobHandlers = new HashMap<>(handlersByBeanName); + for (final var jobHandler : handlersByBeanName.values()) { + this.jobHandlers.put(jobHandler.getJobKey(), jobHandler); + } + } + + @SuppressWarnings("unchecked,rawtypes") + @Scheduled(fixedDelay = 200) + @Transactional + public void runOneJob() { + final Optional nextJob = this.jobRepository.claimNext(Thread.currentThread().getName()); + if (nextJob.isPresent()) { + if (this.jobHandlers == null) { + this.initJobHandlers(); + } + final Job job = nextJob.get(); + final JobHandler jobHandler = this.jobHandlers.get(job.getJobKey()); + if (jobHandler == null) { + throw new RuntimeException("There is no registered job handler for " + job.getJobKey()); + } + final Object payload = this.objectMapper.convertValue( + job.getPayload(), + jobHandler.getPayloadType() + ); + try { + jobHandler.handle(job, payload); + job.setState(Job.State.DONE); + job.setModified(OffsetDateTime.now()); + job.setLockedBy(null); + job.setLockedAt(null); + this.jobRepository.save(job); + } catch (Exception e) { + final int attemptCount = job.getAttempts() + 1; + final boolean isDead = attemptCount >= job.getMaxAttempts(); + final OffsetDateTime runAfter = isDead + ? OffsetDateTime.now() + : OffsetDateTime.now().plusSeconds(getBackoffSeconds(attemptCount)); + final String lastError = formatException(e); + + job.setState(isDead ? Job.State.DEAD : Job.State.QUEUED); + job.setAttempts(attemptCount); + job.setRunAfter(runAfter); + job.setLastError(lastError); + job.setModified(OffsetDateTime.now()); + job.setLockedBy(null); + job.setLockedAt(null); + + this.jobRepository.save(job); + } + } + } + + private static long getBackoffSeconds(int attemptCount) { + final long base = (long) Math.min(300, Math.pow(2, attemptCount)); + final long jitter = ThreadLocalRandom.current().nextLong(0, 5); + return base + jitter; + } + + private static String formatException(Exception e) { + final var sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + final String s = sw.toString(); + return s.length() <= 8000 ? s : s.substring(0, 8000); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java new file mode 100644 index 0000000..6b8dcdc --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java @@ -0,0 +1,63 @@ +package app.mealsmadeeasy.api.recipe; + +import app.mealsmadeeasy.api.image.Image; +import app.mealsmadeeasy.api.user.User; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.Type; +import org.jetbrains.annotations.Nullable; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "recipe_draft") +@Data +public class RecipeDraft { + + public enum State { + INFER, + ENTER_DATA + } + + @Data + public static class RecipeDraftInference { + private OffsetDateTime inferredAt; + private String title; + private String rawText; + } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, unique = true, updatable = false) + private UUID id; + + @Column(nullable = false) + private OffsetDateTime created; + private @Nullable OffsetDateTime modified; + + @Column(nullable = false) + private State state; + + private @Nullable String slug; + private @Nullable String title; + private @Nullable Integer preparationTime; + private @Nullable Integer cookingTime; + private @Nullable Integer totalTime; + private @Nullable String rawText; + + @ManyToOne(optional = false) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; + + @ManyToOne + @JoinColumn(name = "main_image_id") + private @Nullable Image mainImage; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb") + private @Nullable List inferences; + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java new file mode 100644 index 0000000..c3d6b41 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java @@ -0,0 +1,7 @@ +package app.mealsmadeeasy.api.recipe; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface RecipeDraftRepository extends JpaRepository {} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index b8dd48e..5b8a63c 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -1,17 +1,22 @@ package app.mealsmadeeasy.api.recipe; +import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.image.Image; import app.mealsmadeeasy.api.image.ImageException; import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.image.view.ImageView; +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.RecipeInferJobHandler; +import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository; import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; +import jakarta.transaction.Transactional; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; @@ -27,6 +32,7 @@ import java.time.OffsetDateTime; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; @Service public class RecipeService { @@ -36,19 +42,25 @@ public class RecipeService { private final ImageService imageService; private final MarkdownService markdownService; private final EmbeddingModel embeddingModel; + private final RecipeDraftRepository recipeDraftRepository; + private final JobService jobService; public RecipeService( RecipeRepository recipeRepository, RecipeStarRepository recipeStarRepository, ImageService imageService, MarkdownService markdownService, - EmbeddingModel embeddingModel + EmbeddingModel embeddingModel, + RecipeDraftRepository recipeDraftRepository, + JobService jobService ) { this.recipeRepository = recipeRepository; this.recipeStarRepository = recipeStarRepository; this.imageService = imageService; this.markdownService = markdownService; this.embeddingModel = embeddingModel; + this.recipeDraftRepository = recipeDraftRepository; + this.jobService = jobService; } public Recipe create(@Nullable User owner, RecipeCreateSpec spec) { @@ -314,4 +326,41 @@ public class RecipeService { return viewer.getUsername().equals(username); } + 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); + } + + @Transactional + 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); + + this.jobService.create( + RecipeInferJobHandler.JOB_KEY, + new RecipeInferJobPayload(saved.getId(), sourceFile.getId()), + 1, + OffsetDateTime.now() + ); + + return saved; + } + + public RecipeDraft getDraftById(UUID id) { + return this.recipeDraftRepository.findById(id).orElseThrow(() -> new RuntimeException( + "RecipeDraft with id " + id + " not found" + )); + } + + public RecipeDraft saveDraft(RecipeDraft recipeDraft) { + return this.recipeDraftRepository.save(recipeDraft); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java new file mode 100644 index 0000000..8a8f1bc --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java @@ -0,0 +1,95 @@ +package app.mealsmadeeasy.api.recipe.job; + +import app.mealsmadeeasy.api.file.File; +import app.mealsmadeeasy.api.file.FileService; +import app.mealsmadeeasy.api.job.Job; +import app.mealsmadeeasy.api.job.JobHandler; +import app.mealsmadeeasy.api.recipe.RecipeDraft; +import app.mealsmadeeasy.api.recipe.RecipeService; +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.core.io.InputStreamResource; +import org.springframework.stereotype.Component; +import org.springframework.util.MimeType; + +import java.io.IOException; +import java.io.InputStream; +import java.time.OffsetDateTime; +import java.util.ArrayList; + +@Component +@RequiredArgsConstructor +public class RecipeInferJobHandler implements JobHandler { + + public static final String JOB_KEY = "RECIPE_INFER_JOB"; + + private final FileService fileService; + private final RecipeService recipeService; + private final ChatModel chatModel; + + @Override + public Class getPayloadType() { + return RecipeInferJobPayload.class; + } + + @Override + public String getJobKey() { + return "RECIPE_INFER_JOB"; + } + + @Override + public void handle(Job job, RecipeInferJobPayload payload) { + final File sourceFile = this.fileService.getById(payload.fileId()); + final InputStream sourceFileContent; + try { + sourceFileContent = this.fileService.getFileContentById(payload.fileId()); + } catch (IOException e) { + throw new RuntimeException(e); + } + + final Media sourceFileMedia = Media.builder() + .data(new InputStreamResource(sourceFileContent)) + .mimeType(MimeType.valueOf(sourceFile.getMimeType())) + .build(); + + final Message ocrMessage = UserMessage.builder() + .text("Convert the recipe in the image 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(); + + // get recipe draft + final @Nullable RecipeDraft recipeDraft = this.recipeService.getDraftById(payload.recipeDraftId()); + if (recipeDraft == null) { + throw new RuntimeException("Recipe draft not found"); + } + + // set props on draft from ai output, etc. + recipeDraft.setRawText(fullMarkdownText); + recipeDraft.setState(RecipeDraft.State.ENTER_DATA); + + if (recipeDraft.getInferences() == null) { + recipeDraft.setInferences(new ArrayList<>()); + } + final RecipeDraft.RecipeDraftInference inference = new RecipeDraft.RecipeDraftInference(); + inference.setTitle("TODO: inferred title"); + inference.setRawText(fullMarkdownText); + inference.setInferredAt(OffsetDateTime.now()); + recipeDraft.getInferences().add(inference); + + this.recipeService.saveDraft(recipeDraft); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobPayload.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobPayload.java new file mode 100644 index 0000000..e124095 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobPayload.java @@ -0,0 +1,5 @@ +package app.mealsmadeeasy.api.recipe.job; + +import java.util.UUID; + +public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {} diff --git a/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java b/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java new file mode 100644 index 0000000..c9fd78d --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java @@ -0,0 +1,46 @@ +package app.mealsmadeeasy.api.util; + +import org.springframework.stereotype.Service; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class MimeTypeService { + + private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$"); + + private static final String IMAGE_JPEG = "image/jpeg"; + private static final String IMAGE_PNG = "image/png"; + private static final String IMAGE_SVG = "image/svg+xml"; + private static final String IMAGE_WEBP = "image/webp"; + + public String getMimeType(String userFilename) { + final Matcher m = extensionPattern.matcher(userFilename); + if (m.matches()) { + final String extension = m.group(1); + return switch (extension) { + case "jpg", "jpeg" -> IMAGE_JPEG; + case "png" -> IMAGE_PNG; + case "svg" -> IMAGE_SVG; + case "webp" -> IMAGE_WEBP; + default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension); + }; + } else { + throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename); + } + } + + public String getExtension(String mimeType) { + return switch (mimeType) { + case IMAGE_JPEG -> "jpg"; + case IMAGE_PNG -> "png"; + case IMAGE_SVG -> "svg"; + case IMAGE_WEBP -> "webp"; + default -> throw new IllegalArgumentException( + "Cannot determine extension from given mimetype: " + mimeType + ); + }; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0a5d6ba..50d7a28 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,6 +16,7 @@ app.mealsmadeeasy.api.minio.endpoint=http://${MINIO_HOST:localhost}:${MINIO_PORT app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER} app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD} app.mealsmadeeasy.api.images.bucketName=images +app.mealsmadeeasy.api.files.bucketName=files # AI spring.ai.vectorstore.pgvector.dimensions=1024 diff --git a/src/main/resources/db/migration/V5__create_recipe_draft.sql b/src/main/resources/db/migration/V5__create_recipe_draft.sql new file mode 100644 index 0000000..450526d --- /dev/null +++ b/src/main/resources/db/migration/V5__create_recipe_draft.sql @@ -0,0 +1,17 @@ +CREATE TABLE recipe_draft ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created TIMESTAMPTZ(6) NOT NULL, + modified TIMESTAMPTZ(6), + state INT NOT NULL, + slug VARCHAR(255), + title VARCHAR(255), + preparation_time INT, + cooking_time INT, + total_time INT, + raw_text TEXT, + owner_id INT NOT NULL, + main_image_id INT, + inferences JSONB, + FOREIGN KEY (owner_id) REFERENCES "user", + FOREIGN KEY (main_image_id) REFERENCES image +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V6__create_job.sql b/src/main/resources/db/migration/V6__create_job.sql new file mode 100644 index 0000000..04d6418 --- /dev/null +++ b/src/main/resources/db/migration/V6__create_job.sql @@ -0,0 +1,14 @@ +CREATE TABLE job ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created TIMESTAMPTZ NOT NULL, + modified TIMESTAMPTZ, + state VARCHAR(64) NOT NULL, + job_key VARCHAR(255) NOT NULL, + payload JSONB NOT NULL, + attempts INT NOT NULL, + max_attempts INT NOT NULL, + run_after TIMESTAMPTZ NOT NULL, + locked_by VARCHAR(255), + locked_at TIMESTAMPTZ, + last_error TEXT +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V7__create_file.sql b/src/main/resources/db/migration/V7__create_file.sql new file mode 100644 index 0000000..67a049d --- /dev/null +++ b/src/main/resources/db/migration/V7__create_file.sql @@ -0,0 +1,9 @@ +CREATE TABLE file ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created TIMESTAMPTZ NOT NULL, + user_filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(255) NOT NULL, + object_name VARCHAR(255) NOT NULL, + owner_id INT NOT NULL, + FOREIGN KEY (owner_id) REFERENCES "user" +); diff --git a/src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java b/src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java new file mode 100644 index 0000000..660110b --- /dev/null +++ b/src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java @@ -0,0 +1,98 @@ +package app.mealsmadeeasy.api.job; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; + +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class JobServiceTests { + + @Mock + private JobRepository jobRepository; + + @Mock + private ApplicationContext applicationContext; + + @Spy + private ObjectMapper objectMapper; + + @InjectMocks + private JobService jobService; + + @Test + public void whenRunOneJob_callsClaimNext() { + when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.empty()); + this.jobService.runOneJob(); + verify(this.jobRepository).claimNext(anyString()); + } + + @Test + public void findsJobHandlerAndSavesJobInfo() { + final Job testJob = new Job(); + testJob.setJobKey("TEST_JOB_KEY"); + + @SuppressWarnings("unchecked") + final JobHandler testJobHandler = mock(JobHandler.class); + when(testJobHandler.getPayloadType()).thenReturn(Object.class); + when(testJobHandler.getJobKey()).thenReturn("TEST_JOB_KEY"); + + when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob)); + when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn( + Map.of("testJobHandler", testJobHandler) + ); + + this.jobService.runOneJob(); + + verify(testJobHandler).handle(testJob, null); + verify(this.jobRepository).save(testJob); + + assertThat(testJob.getState(), is(Job.State.DONE)); + assertThat(testJob.getModified(), is(notNullValue())); + assertThat(testJob.getLockedBy(), is(nullValue())); + assertThat(testJob.getLockedAt(), is(nullValue())); + } + + @Test + public void retryJobAfterThrowingHandler() { + final Job testJob = new Job(); + testJob.setAttempts(0); + testJob.setMaxAttempts(3); + testJob.setJobKey("TEST_JOB_KEY"); + + final JobHandler throwingHandler = mock(JobHandler.class); + when(throwingHandler.getPayloadType()).thenReturn(Object.class); + when(throwingHandler.getJobKey()).thenReturn("TEST_JOB_KEY"); + doThrow(new RuntimeException("TO THROW")).when(throwingHandler).handle(testJob, null); + + when(this.jobRepository.claimNext(anyString())).thenReturn(Optional.of(testJob)); + when(this.applicationContext.getBeansOfType(JobHandler.class)).thenReturn( + Map.of("testJobHandler", throwingHandler) + ); + + this.jobService.runOneJob(); + + verify(throwingHandler).handle(testJob, null); + verify(this.jobRepository).save(testJob); + + assertThat(testJob.getState(), is(Job.State.QUEUED)); + assertThat(testJob.getAttempts(), is(1)); + assertThat(testJob.getRunAfter(), is(notNullValue())); + assertThat(testJob.getLastError(), is(notNullValue())); + assertThat(testJob.getModified(), is(notNullValue())); + assertThat(testJob.getLockedBy(), is(nullValue())); + assertThat(testJob.getLockedAt(), is(nullValue())); + } + +}