Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/dev/barboud/taglens/TagLensApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/dev/barboud/taglens/configuration/GeminiConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,14 @@ public class ImageController {


@PostMapping("/upload")
public ResponseEntity<ImageResponseDto> uploadImage(
public ResponseEntity<Void> 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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Map;
import java.util.UUID;

@Data
Expand All @@ -12,4 +13,5 @@ public class ImageResponseDto {
String contentType;
Long fileSize;
private String storageKey;
Map<String, Object> tags;
}
6 changes: 6 additions & 0 deletions src/main/java/dev/barboud/taglens/model/Image.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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;
private String storageKey;
private String contentType;
private Long fileSize;
private Instant createdAt;
private Map<String, Object> tags;
}
25 changes: 22 additions & 3 deletions src/main/java/dev/barboud/taglens/repository/ImageRepository.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,6 +17,13 @@
public class ImageRepository {

private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;


private Map<String, Object> parseTags(String tagsJson) {
if (tagsJson == null) return null;
return objectMapper.readValue(tagsJson, new TypeReference<Map<String, Object>>() {});
}

public void saveImage(Image image) {

Expand All @@ -34,7 +43,7 @@ public void saveImage(Image image) {

public Optional<Image> 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 = ?
""";
Expand All @@ -47,6 +56,7 @@ public Optional<Image> 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);

Expand All @@ -63,7 +73,7 @@ public void deleteImage(UUID imageId) {

public List<Image> 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 = ?
""";
Expand All @@ -75,10 +85,19 @@ public List<Image> 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<String, Object> tags) {
String json = objectMapper.writeValueAsString(tags);
jdbcTemplate.update(
"UPDATE images SET tags = ?::jsonb WHERE id = ?",
json, imageId
);
}

}
15 changes: 7 additions & 8 deletions src/main/java/dev/barboud/taglens/service/ImageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());

}


Expand Down Expand Up @@ -114,7 +112,8 @@ public List<ImageResponseDto> getMyImages(UUID sessionId){
image.getId(),
image.getContentType(),
image.getFileSize(),
image.getStorageKey()
image.getStorageKey(),
image.getTags()
))
.toList();

Expand Down
94 changes: 94 additions & 0 deletions src/main/java/dev/barboud/taglens/service/TaggingService.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> 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<Map<String, Object>>() {});
}

@Async
public void tagImageAsync(UUID imageId, byte[] imageBytes, String contentType) {
Map<String, Object> tags = generateTags(imageBytes, contentType);
imageRepository.updateImageTags(imageId, tags);
log.info("Tags saved for image={}, tags={}", imageId, tags);
}

}
5 changes: 4 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ b2.bucket=${B2_BUCKET_NAME}


spring.servlet.multipart.max-file-size=15MB
spring.servlet.multipart.max-request-size=15MB
spring.servlet.multipart.max-request-size=15MB


gemini.api.key=${GEMINI_API_KEY}
3 changes: 3 additions & 0 deletions src/main/resources/db/migration/V5__add_tags_to_images.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE images ADD COLUMN tags JSONB;

CREATE INDEX idx_images_tags ON images USING GIN (tags);