Service and data layer handling of jobs, files, and recipe inferences.

This commit is contained in:
Jesse Brault 2026-01-16 20:37:58 -06:00
parent 012bf743a1
commit 7f985f3434
26 changed files with 924 additions and 65 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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"
));
}
}

View File

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

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

View File

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

View 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);
}

View 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);
}

View 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);
}
}

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package app.mealsmadeeasy.api.recipe.job;
import java.util.UUID;
public record RecipeInferJobPayload(UUID recipeDraftId, UUID fileId) {}

View File

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

View File

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

View 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
);

View 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
);

View 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"
);

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