Service and data layer handling of jobs, files, and recipe inferences.
This commit is contained in:
parent
012bf743a1
commit
7f985f3434
@ -80,6 +80,9 @@ dependencies {
|
|||||||
implementation 'org.jsoup:jsoup:1.21.2'
|
implementation 'org.jsoup:jsoup:1.21.2'
|
||||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1'
|
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'
|
implementation 'io.minio:minio:8.6.0'
|
||||||
|
|
||||||
compileOnly 'org.jetbrains:annotations:26.0.2-1'
|
compileOnly 'org.jetbrains:annotations:26.0.2-1'
|
||||||
@ -98,6 +101,10 @@ dependencies {
|
|||||||
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
|
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
|
||||||
testImplementation 'org.testcontainers:postgresql:1.21.4'
|
testImplementation 'org.testcontainers:postgresql:1.21.4'
|
||||||
testImplementation 'org.testcontainers:minio: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'
|
testFixturesImplementation 'org.hamcrest:hamcrest:3.0'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<Object> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Class<Object> 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<Object> 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -24,8 +24,7 @@ import java.util.UUID;
|
|||||||
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
|
import static app.mealsmadeeasy.api.recipe.ContainsRecipeInfoViewsForRecipesMatcher.containsRecipeInfoViewsForRecipes;
|
||||||
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
|
import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
|
||||||
import static org.hamcrest.MatcherAssert.assertThat;
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
import static org.hamcrest.Matchers.is;
|
import static org.hamcrest.Matchers.*;
|
||||||
import static org.hamcrest.Matchers.not;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
@ -342,4 +341,13 @@ public class RecipeServiceTests {
|
|||||||
assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<RecipeDraft.RecipeDraftInference> 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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@ -5,6 +5,7 @@ app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000
|
|||||||
app.mealsmadeeasy.api.minio.accessKey=minio-root
|
app.mealsmadeeasy.api.minio.accessKey=minio-root
|
||||||
app.mealsmadeeasy.api.minio.secretKey=test0123
|
app.mealsmadeeasy.api.minio.secretKey=test0123
|
||||||
app.mealsmadeeasy.api.images.bucketName=images
|
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
|
# 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
|
# Posted by Iogui, modified by community. See post 'Timeline' for change history
|
||||||
|
|||||||
36
src/main/java/app/mealsmadeeasy/api/file/File.java
Normal file
36
src/main/java/app/mealsmadeeasy/api/file/File.java
Normal file
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<File, UUID> {}
|
||||||
75
src/main/java/app/mealsmadeeasy/api/file/FileService.java
Normal file
75
src/main/java/app/mealsmadeeasy/api/file/FileService.java
Normal file
@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
|
|||||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
import app.mealsmadeeasy.api.s3.S3Manager;
|
import app.mealsmadeeasy.api.s3.S3Manager;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import app.mealsmadeeasy.api.util.MimeTypeService;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.access.prepost.PostAuthorize;
|
import org.springframework.security.access.prepost.PostAuthorize;
|
||||||
@ -19,63 +20,28 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class S3ImageService implements ImageService {
|
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 S3Manager s3Manager;
|
||||||
private final ImageRepository imageRepository;
|
private final ImageRepository imageRepository;
|
||||||
private final String imageBucketName;
|
private final String imageBucketName;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
|
private final MimeTypeService mimeTypeService;
|
||||||
|
|
||||||
public S3ImageService(
|
public S3ImageService(
|
||||||
S3Manager s3Manager,
|
S3Manager s3Manager,
|
||||||
ImageRepository imageRepository,
|
ImageRepository imageRepository,
|
||||||
@Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
|
@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.s3Manager = s3Manager;
|
||||||
this.imageRepository = imageRepository;
|
this.imageRepository = imageRepository;
|
||||||
this.imageBucketName = imageBucketName;
|
this.imageBucketName = imageBucketName;
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
this.mimeTypeService = mimeTypeService;
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
|
private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
|
||||||
@ -157,9 +123,9 @@ public class S3ImageService implements ImageService {
|
|||||||
long objectSize,
|
long objectSize,
|
||||||
ImageCreateSpec createSpec
|
ImageCreateSpec createSpec
|
||||||
) throws IOException, ImageException {
|
) throws IOException, ImageException {
|
||||||
final String mimeType = this.getMimeType(userFilename);
|
final String mimeType = this.mimeTypeService.getMimeType(userFilename);
|
||||||
final String uuid = UUID.randomUUID().toString();
|
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 String filename = uuid + "." + extension;
|
||||||
|
|
||||||
final var baos = new ByteArrayOutputStream();
|
final var baos = new ByteArrayOutputStream();
|
||||||
|
|||||||
60
src/main/java/app/mealsmadeeasy/api/job/Job.java
Normal file
60
src/main/java/app/mealsmadeeasy/api/job/Job.java
Normal file
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
|
|
||||||
}
|
|
||||||
7
src/main/java/app/mealsmadeeasy/api/job/JobHandler.java
Normal file
7
src/main/java/app/mealsmadeeasy/api/job/JobHandler.java
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package app.mealsmadeeasy.api.job;
|
||||||
|
|
||||||
|
public interface JobHandler<T> {
|
||||||
|
Class<T> getPayloadType();
|
||||||
|
String getJobKey();
|
||||||
|
void handle(Job job, T payload);
|
||||||
|
}
|
||||||
27
src/main/java/app/mealsmadeeasy/api/job/JobRepository.java
Normal file
27
src/main/java/app/mealsmadeeasy/api/job/JobRepository.java
Normal file
@ -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<Job, UUID> {
|
||||||
|
|
||||||
|
@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<Job> claimNext(String lockedBy);
|
||||||
|
|
||||||
|
}
|
||||||
119
src/main/java/app/mealsmadeeasy/api/job/JobService.java
Normal file
119
src/main/java/app/mealsmadeeasy/api/job/JobService.java
Normal file
@ -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<String, JobHandler> 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<String, JobHandler> 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<Job> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
63
src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java
Normal file
63
src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraft.java
Normal file
@ -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<RecipeDraftInference> inferences;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<RecipeDraft, UUID> {}
|
||||||
@ -1,17 +1,22 @@
|
|||||||
package app.mealsmadeeasy.api.recipe;
|
package app.mealsmadeeasy.api.recipe;
|
||||||
|
|
||||||
|
import app.mealsmadeeasy.api.file.File;
|
||||||
import app.mealsmadeeasy.api.image.Image;
|
import app.mealsmadeeasy.api.image.Image;
|
||||||
import app.mealsmadeeasy.api.image.ImageException;
|
import app.mealsmadeeasy.api.image.ImageException;
|
||||||
import app.mealsmadeeasy.api.image.ImageService;
|
import app.mealsmadeeasy.api.image.ImageService;
|
||||||
import app.mealsmadeeasy.api.image.view.ImageView;
|
import app.mealsmadeeasy.api.image.view.ImageView;
|
||||||
|
import app.mealsmadeeasy.api.job.JobService;
|
||||||
import app.mealsmadeeasy.api.markdown.MarkdownService;
|
import app.mealsmadeeasy.api.markdown.MarkdownService;
|
||||||
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
|
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.RecipeCreateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
|
||||||
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
|
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
|
||||||
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
|
||||||
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
import app.mealsmadeeasy.api.recipe.view.RecipeInfoView;
|
||||||
import app.mealsmadeeasy.api.user.User;
|
import app.mealsmadeeasy.api.user.User;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
import org.jetbrains.annotations.ApiStatus;
|
import org.jetbrains.annotations.ApiStatus;
|
||||||
import org.jetbrains.annotations.Contract;
|
import org.jetbrains.annotations.Contract;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
@ -27,6 +32,7 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class RecipeService {
|
public class RecipeService {
|
||||||
@ -36,19 +42,25 @@ public class RecipeService {
|
|||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
private final MarkdownService markdownService;
|
private final MarkdownService markdownService;
|
||||||
private final EmbeddingModel embeddingModel;
|
private final EmbeddingModel embeddingModel;
|
||||||
|
private final RecipeDraftRepository recipeDraftRepository;
|
||||||
|
private final JobService jobService;
|
||||||
|
|
||||||
public RecipeService(
|
public RecipeService(
|
||||||
RecipeRepository recipeRepository,
|
RecipeRepository recipeRepository,
|
||||||
RecipeStarRepository recipeStarRepository,
|
RecipeStarRepository recipeStarRepository,
|
||||||
ImageService imageService,
|
ImageService imageService,
|
||||||
MarkdownService markdownService,
|
MarkdownService markdownService,
|
||||||
EmbeddingModel embeddingModel
|
EmbeddingModel embeddingModel,
|
||||||
|
RecipeDraftRepository recipeDraftRepository,
|
||||||
|
JobService jobService
|
||||||
) {
|
) {
|
||||||
this.recipeRepository = recipeRepository;
|
this.recipeRepository = recipeRepository;
|
||||||
this.recipeStarRepository = recipeStarRepository;
|
this.recipeStarRepository = recipeStarRepository;
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
this.markdownService = markdownService;
|
this.markdownService = markdownService;
|
||||||
this.embeddingModel = embeddingModel;
|
this.embeddingModel = embeddingModel;
|
||||||
|
this.recipeDraftRepository = recipeDraftRepository;
|
||||||
|
this.jobService = jobService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
|
public Recipe create(@Nullable User owner, RecipeCreateSpec spec) {
|
||||||
@ -314,4 +326,41 @@ public class RecipeService {
|
|||||||
return viewer.getUsername().equals(username);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<RecipeInferJobPayload> {
|
||||||
|
|
||||||
|
public static final String JOB_KEY = "RECIPE_INFER_JOB";
|
||||||
|
|
||||||
|
private final FileService fileService;
|
||||||
|
private final RecipeService recipeService;
|
||||||
|
private final ChatModel chatModel;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Class<RecipeInferJobPayload> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package app.mealsmadeeasy.api.recipe.job;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {}
|
||||||
@ -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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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.accessKey=${MINIO_ROOT_USER}
|
||||||
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
|
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
|
||||||
app.mealsmadeeasy.api.images.bucketName=images
|
app.mealsmadeeasy.api.images.bucketName=images
|
||||||
|
app.mealsmadeeasy.api.files.bucketName=files
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
spring.ai.vectorstore.pgvector.dimensions=1024
|
spring.ai.vectorstore.pgvector.dimensions=1024
|
||||||
|
|||||||
17
src/main/resources/db/migration/V5__create_recipe_draft.sql
Normal file
17
src/main/resources/db/migration/V5__create_recipe_draft.sql
Normal file
@ -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
|
||||||
|
);
|
||||||
14
src/main/resources/db/migration/V6__create_job.sql
Normal file
14
src/main/resources/db/migration/V6__create_job.sql
Normal file
@ -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
|
||||||
|
);
|
||||||
9
src/main/resources/db/migration/V7__create_file.sql
Normal file
9
src/main/resources/db/migration/V7__create_file.sql
Normal file
@ -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"
|
||||||
|
);
|
||||||
98
src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java
Normal file
98
src/test/java/app/mealsmadeeasy/api/job/JobServiceTests.java
Normal file
@ -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<Object> 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<Object> 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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user