diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66cedcb..c5241ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: LOKI_URL: http://localhost:3100 LOKI_USERNAME: dummy LOKI_API_TOKEN: dummy + GEMINI_API_KEY: dummy build-and-push: runs-on: ubuntu-latest diff --git a/src/main/java/dev/barboud/taglens/TagLensApplication.java b/src/main/java/dev/barboud/taglens/TagLensApplication.java index 565ac90..3d29a2b 100644 --- a/src/main/java/dev/barboud/taglens/TagLensApplication.java +++ b/src/main/java/dev/barboud/taglens/TagLensApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class TagLensApplication { public static void main(String[] args) { diff --git a/src/main/java/dev/barboud/taglens/configuration/GeminiConfig.java b/src/main/java/dev/barboud/taglens/configuration/GeminiConfig.java new file mode 100644 index 0000000..a8b9c43 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/configuration/GeminiConfig.java @@ -0,0 +1,21 @@ +package dev.barboud.taglens.configuration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class GeminiConfig { + + @Value("${gemini.api.key}") + private String apiKey; + + @Bean + public RestClient geminiClient() { + return RestClient.builder() + .baseUrl("https://generativelanguage.googleapis.com/v1beta") + .defaultHeader("Content-Type", "application/json") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/barboud/taglens/controller/ImageController.java b/src/main/java/dev/barboud/taglens/controller/ImageController.java index a6b976f..0912d85 100644 --- a/src/main/java/dev/barboud/taglens/controller/ImageController.java +++ b/src/main/java/dev/barboud/taglens/controller/ImageController.java @@ -23,16 +23,14 @@ public class ImageController { @PostMapping("/upload") - public ResponseEntity uploadImage( + public ResponseEntity uploadImage( @RequestParam("file") MultipartFile file, @CookieValue("session_id") UUID sessionId ) throws Exception { - ImageResponseDto imageResponseDto = imageService.uploadImage(file, sessionId); + imageService.uploadImage(file, sessionId); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(imageResponseDto); + return ResponseEntity.noContent().build(); } @GetMapping("/{id}") diff --git a/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java b/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java index 9c8375f..63fa053 100644 --- a/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java +++ b/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java @@ -3,6 +3,7 @@ import lombok.AllArgsConstructor; import lombok.Data; +import java.util.Map; import java.util.UUID; @Data @@ -12,4 +13,5 @@ public class ImageResponseDto { String contentType; Long fileSize; private String storageKey; + Map tags; } diff --git a/src/main/java/dev/barboud/taglens/model/Image.java b/src/main/java/dev/barboud/taglens/model/Image.java index 15071ce..1d37512 100644 --- a/src/main/java/dev/barboud/taglens/model/Image.java +++ b/src/main/java/dev/barboud/taglens/model/Image.java @@ -1,11 +1,16 @@ package dev.barboud.taglens.model; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.Instant; +import java.util.Map; import java.util.UUID; @Data +@AllArgsConstructor +@NoArgsConstructor public class Image { private UUID id; private UUID userId; @@ -13,4 +18,5 @@ public class Image { private String contentType; private Long fileSize; private Instant createdAt; + private Map tags; } diff --git a/src/main/java/dev/barboud/taglens/repository/ImageRepository.java b/src/main/java/dev/barboud/taglens/repository/ImageRepository.java index 3edf766..7788d5d 100644 --- a/src/main/java/dev/barboud/taglens/repository/ImageRepository.java +++ b/src/main/java/dev/barboud/taglens/repository/ImageRepository.java @@ -1,12 +1,14 @@ package dev.barboud.taglens.repository; -import dev.barboud.taglens.dto.image.ImageResponseDto; import dev.barboud.taglens.model.Image; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -15,6 +17,13 @@ public class ImageRepository { private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + + private Map parseTags(String tagsJson) { + if (tagsJson == null) return null; + return objectMapper.readValue(tagsJson, new TypeReference>() {}); + } public void saveImage(Image image) { @@ -34,7 +43,7 @@ public void saveImage(Image image) { public Optional findImageById(UUID imageId) { String sql = """ - SELECT id, user_id, storage_key, content_type, file_size, created_at + SELECT id, user_id, storage_key, content_type, file_size, created_at, tags FROM images WHERE id = ? """; @@ -47,6 +56,7 @@ public Optional findImageById(UUID imageId) { image.setContentType(rs.getString("content_type")); image.setFileSize(rs.getLong("file_size")); image.setCreatedAt(rs.getTimestamp("created_at").toInstant()); + image.setTags(parseTags(rs.getString("tags"))); return image; }, imageId); @@ -63,7 +73,7 @@ public void deleteImage(UUID imageId) { public List findImagesByUserId(UUID userId) { String sql = """ - SELECT id, user_id, storage_key, content_type, file_size, created_at + SELECT id, user_id, storage_key, content_type, file_size, created_at, tags FROM images WHERE user_id = ? """; @@ -75,10 +85,19 @@ public List findImagesByUserId(UUID userId) { image.setContentType(rs.getString("content_type")); image.setFileSize(rs.getLong("file_size")); image.setCreatedAt(rs.getTimestamp("created_at").toInstant()); + image.setTags(parseTags(rs.getString("tags"))); return image; }, userId); return results; } + public void updateImageTags(UUID imageId, Map tags) { + String json = objectMapper.writeValueAsString(tags); + jdbcTemplate.update( + "UPDATE images SET tags = ?::jsonb WHERE id = ?", + json, imageId + ); + } + } \ No newline at end of file diff --git a/src/main/java/dev/barboud/taglens/service/ImageService.java b/src/main/java/dev/barboud/taglens/service/ImageService.java index 99b399d..14c3405 100644 --- a/src/main/java/dev/barboud/taglens/service/ImageService.java +++ b/src/main/java/dev/barboud/taglens/service/ImageService.java @@ -20,10 +20,11 @@ public class ImageService { private final SessionRepository sessionRepository; private final ImageRepository imageRepository; private final StorageService storageService; + private final TaggingService taggingService; private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB - public ImageResponseDto uploadImage( + public void uploadImage( MultipartFile file, UUID sessionId ) throws Exception { @@ -67,12 +68,9 @@ public ImageResponseDto uploadImage( // 7. Save image metadata in Database imageRepository.saveImage(image); - return new ImageResponseDto( - image.getId(), - image.getContentType(), - image.getFileSize(), - image.getStorageKey() - ); + // 8. Generate Ai Tags + taggingService.tagImageAsync(image.getId(), file.getBytes(), file.getContentType()); + } @@ -114,7 +112,8 @@ public List getMyImages(UUID sessionId){ image.getId(), image.getContentType(), image.getFileSize(), - image.getStorageKey() + image.getStorageKey(), + image.getTags() )) .toList(); diff --git a/src/main/java/dev/barboud/taglens/service/TaggingService.java b/src/main/java/dev/barboud/taglens/service/TaggingService.java new file mode 100644 index 0000000..1b705a1 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/service/TaggingService.java @@ -0,0 +1,94 @@ +package dev.barboud.taglens.service; + +import dev.barboud.taglens.repository.ImageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaggingService { + + private final RestClient geminiClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ImageRepository imageRepository; + + @Value("${gemini.api.key}") + private String apiKey; + + public String generateTagsRaw(byte[] imageBytes, String contentType) { + String base64Image = Base64.getEncoder().encodeToString(imageBytes); + + String prompt = """ + You are an expert AI image analysis system. Your job is to analyze the provided image and generate a valid JSON object containing tags separated into three distinct categories: "objects", "tags", and "colors". + ### Guidelines & Rules: + 1. **objects**: + - List physical, tangible items present in the image (e.g., "tree", "car", "cloud", "sun", "boat"). + - Keep names clear and concise in lowercase English. + 2. **tags**: + - List non-object descriptors describing the scene, setting, time of day, weather, mood, style, or atmosphere (e.g., "sunset", "tropical", "silhouette", "reflection on water"). + 3. **colors**: + - Identify up to 3 of the most prominent colors in the image. + - You MUST ONLY select from the 11 basic colors in English: ["black", "blue", "brown", "gray", "green", "orange", "pink", "purple", "red", "white", "yellow"]. + ### Output Requirements: + - Respond strictly with valid JSON. + - Do NOT wrap the output in markdown extra text or explanations. + - Follow this exact schema format: + { + "objects": ["string"], + "tags": ["string"], + "colors": ["string"] + } + """; + + Map body = Map.of( + "contents", new Object[] { + Map.of("parts", new Object[] { + Map.of("text", prompt), + Map.of("inline_data", Map.of( + "mime_type", contentType, + "data", base64Image + )) + }) + } + ); + + return geminiClient.post() + .uri("/models/gemini-3.6-flash:generateContent?key=" + apiKey) + .body(body) + .retrieve() + .body(String.class); + } + + public Map generateTags(byte[] imageBytes, String contentType) { + String rawResponse = generateTagsRaw(imageBytes, contentType); + + JsonNode root = objectMapper.readTree(rawResponse); + String text = root.path("candidates").get(0) + .path("content").path("parts").get(0) + .path("text").asText(); + + text = text.replaceAll("```json|```", "").trim(); + + return objectMapper.readValue(text, new TypeReference>() {}); + } + + @Async + public void tagImageAsync(UUID imageId, byte[] imageBytes, String contentType) { + Map tags = generateTags(imageBytes, contentType); + imageRepository.updateImageTags(imageId, tags); + log.info("Tags saved for image={}, tags={}", imageId, tags); + } + +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2be0e11..7e3aeb6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,4 +16,7 @@ b2.bucket=${B2_BUCKET_NAME} spring.servlet.multipart.max-file-size=15MB -spring.servlet.multipart.max-request-size=15MB \ No newline at end of file +spring.servlet.multipart.max-request-size=15MB + + +gemini.api.key=${GEMINI_API_KEY} \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__add_tags_to_images.sql b/src/main/resources/db/migration/V5__add_tags_to_images.sql new file mode 100644 index 0000000..cfbbe71 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_tags_to_images.sql @@ -0,0 +1,3 @@ +ALTER TABLE images ADD COLUMN tags JSONB; + +CREATE INDEX idx_images_tags ON images USING GIN (tags); \ No newline at end of file