s3 bucket integration for image storage
This commit is contained in:
@@ -32,6 +32,13 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>bom</artifactId>
|
||||||
|
<version>2.30.14</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
@@ -108,6 +115,12 @@
|
|||||||
<version>3.0.3</version>
|
<version>3.0.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- AWS SDK v2 — S3 figure storage -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>s3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Jackson (JSON) -->
|
<!-- Jackson (JSON) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ public class BookEmbeddingService {
|
|||||||
|
|
||||||
// Step 4: For each figure, generate vision description and embed caption
|
// Step 4: For each figure, generate vision description and embed caption
|
||||||
for (FigureEntity figure : figures) {
|
for (FigureEntity figure : figures) {
|
||||||
Path imagePath = figureStorageService.resolve(figure.getImagePath());
|
byte[] imageBytes = figureStorageService.getBytes(figure.getImagePath());
|
||||||
String description = visionDescriptionService.describe(
|
String description = visionDescriptionService.describe(
|
||||||
imagePath, figure.getCaption());
|
imageBytes, figure.getCaption());
|
||||||
|
|
||||||
// Use description as caption fallback if no caption was detected
|
// Use description as caption fallback if no caption was detected
|
||||||
if (figure.getCaption() == null || figure.getCaption().isBlank()) {
|
if (figure.getCaption() == null || figure.getCaption().isBlank()) {
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
package com.aiteacher.config;
|
package com.aiteacher.config;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import com.aiteacher.figure.FigureStorageService;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.nio.file.Paths;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
@Configuration
|
/**
|
||||||
public class FigureStorageConfig implements WebMvcConfigurer {
|
* Serves figure images by redirecting to a presigned S3 URL.
|
||||||
|
* The key stored in DB is the full S3 object key, e.g. "figures/{bookId}/{figureId}.png".
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/figures")
|
||||||
|
public class FigureStorageConfig {
|
||||||
|
|
||||||
private final String basePath;
|
private final FigureStorageService figureStorageService;
|
||||||
|
|
||||||
public FigureStorageConfig(@Value("${app.figure-storage.base-path:./uploads}") String basePath) {
|
public FigureStorageConfig(FigureStorageService figureStorageService) {
|
||||||
this.basePath = Paths.get(basePath).toAbsolutePath().normalize().toString();
|
this.figureStorageService = figureStorageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@GetMapping("/{bookId}/{filename}")
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void serve(@PathVariable String bookId,
|
||||||
// Serve GET /api/v1/figures/** from the local file store
|
@PathVariable String filename,
|
||||||
registry.addResourceHandler("/api/v1/figures/**")
|
HttpServletResponse response) throws IOException {
|
||||||
.addResourceLocations("file:" + basePath + "/figures/");
|
String key = "figures/" + bookId + "/" + filename;
|
||||||
|
try {
|
||||||
|
String url = figureStorageService.presignedUrl(key);
|
||||||
|
response.sendRedirect(url);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Figure not found: " + key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/v1/figures/**").permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
.httpBasic(Customizer.withDefaults())
|
.httpBasic(Customizer.withDefaults())
|
||||||
.csrf(AbstractHttpConfigurer::disable);
|
.csrf(AbstractHttpConfigurer::disable);
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ package com.aiteacher.document;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.ai.chat.client.ChatClient;
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.MimeTypeUtils;
|
import org.springframework.util.MimeTypeUtils;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a clinical text description for an extracted figure image
|
* Generates a clinical text description for an extracted figure image
|
||||||
* using the OpenAI vision model via Spring AI ChatClient.
|
* using the OpenAI vision model via Spring AI ChatClient.
|
||||||
@@ -32,17 +30,16 @@ public class VisionDescriptionService {
|
|||||||
/**
|
/**
|
||||||
* Returns a description string. Falls back to the provided caption if vision fails.
|
* Returns a description string. Falls back to the provided caption if vision fails.
|
||||||
*/
|
*/
|
||||||
public String describe(Path imagePath, String captionFallback) {
|
public String describe(byte[] imageBytes, String captionFallback) {
|
||||||
try {
|
try {
|
||||||
return chatClient.prompt()
|
return chatClient.prompt()
|
||||||
.user(u -> u
|
.user(u -> u
|
||||||
.text(PROMPT)
|
.text(PROMPT)
|
||||||
.media(MimeTypeUtils.IMAGE_PNG, new FileSystemResource(imagePath.toFile())))
|
.media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(imageBytes)))
|
||||||
.call()
|
.call()
|
||||||
.content();
|
.content();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("Vision description failed for {}: {} — using caption as fallback",
|
log.warn("Vision description failed: {} — using caption as fallback", ex.getMessage());
|
||||||
imagePath.getFileName(), ex.getMessage());
|
|
||||||
return captionFallback != null ? captionFallback : "Figure";
|
return captionFallback != null ? captionFallback : "Figure";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
package com.aiteacher.figure;
|
package com.aiteacher.figure;
|
||||||
|
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface FigureStorageService {
|
public interface FigureStorageService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves an extracted image to the figure store and returns the relative path
|
* Saves an extracted image to S3 and returns the object key stored in the database.
|
||||||
* (relative to the configured base-path) stored in the database.
|
|
||||||
*/
|
*/
|
||||||
String save(UUID bookId, String figureId, BufferedImage image);
|
String save(UUID bookId, String figureId, BufferedImage image);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a stored relative path to an absolute filesystem path.
|
* Downloads the image bytes for the given S3 object key.
|
||||||
*/
|
*/
|
||||||
Path resolve(String relativePath);
|
byte[] getBytes(String key);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes all figure files for the given book.
|
* Returns a presigned GET URL valid for 1 hour for the given S3 object key.
|
||||||
|
*/
|
||||||
|
String presignedUrl(String key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all figure objects for the given book.
|
||||||
*/
|
*/
|
||||||
void deleteAll(UUID bookId);
|
void deleteAll(UUID bookId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package com.aiteacher.figure;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class LocalFigureStorageService implements FigureStorageService {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(LocalFigureStorageService.class);
|
|
||||||
|
|
||||||
private final Path basePath;
|
|
||||||
|
|
||||||
public LocalFigureStorageService(@Value("${app.figure-storage.base-path:./uploads}") String basePath) {
|
|
||||||
this.basePath = Paths.get(basePath).toAbsolutePath().normalize();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String save(UUID bookId, String figureId, BufferedImage image) {
|
|
||||||
try {
|
|
||||||
Path dir = basePath.resolve("figures").resolve(bookId.toString());
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
String filename = figureId + ".png";
|
|
||||||
Path file = dir.resolve(filename);
|
|
||||||
ImageIO.write(image, "PNG", file.toFile());
|
|
||||||
// Return relative path for storage in DB
|
|
||||||
return "figures/" + bookId + "/" + filename;
|
|
||||||
} catch (IOException ex) {
|
|
||||||
throw new RuntimeException("Failed to save figure " + figureId, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Path resolve(String relativePath) {
|
|
||||||
return basePath.resolve(relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void deleteAll(UUID bookId) {
|
|
||||||
Path dir = basePath.resolve("figures").resolve(bookId.toString());
|
|
||||||
if (!Files.exists(dir)) return;
|
|
||||||
try (var walk = Files.walk(dir)) {
|
|
||||||
walk.sorted(java.util.Comparator.reverseOrder())
|
|
||||||
.map(Path::toFile)
|
|
||||||
.forEach(java.io.File::delete);
|
|
||||||
} catch (IOException ex) {
|
|
||||||
log.warn("Could not fully delete figures for book {}: {}", bookId, ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.aiteacher.figure;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.regions.Region;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||||
|
import software.amazon.awssdk.services.s3.model.*;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class S3FigureStorageService implements FigureStorageService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(S3FigureStorageService.class);
|
||||||
|
|
||||||
|
private final S3Client s3;
|
||||||
|
private final S3Presigner presigner;
|
||||||
|
private final String bucket;
|
||||||
|
|
||||||
|
public S3FigureStorageService(
|
||||||
|
@Value("${app.figure-storage.endpoint}") String endpoint,
|
||||||
|
@Value("${app.figure-storage.region}") String region,
|
||||||
|
@Value("${app.figure-storage.bucket}") String bucket,
|
||||||
|
@Value("${app.figure-storage.access-key-id}") String accessKeyId,
|
||||||
|
@Value("${app.figure-storage.secret-access-key}") String secretKey) {
|
||||||
|
this.bucket = bucket;
|
||||||
|
URI endpointUri = URI.create(endpoint);
|
||||||
|
StaticCredentialsProvider credentials = StaticCredentialsProvider.create(
|
||||||
|
AwsBasicCredentials.create(accessKeyId, secretKey));
|
||||||
|
Region awsRegion = Region.of(region);
|
||||||
|
|
||||||
|
S3Configuration s3Config = S3Configuration.builder()
|
||||||
|
.pathStyleAccessEnabled(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.s3 = S3Client.builder()
|
||||||
|
.endpointOverride(endpointUri)
|
||||||
|
.region(awsRegion)
|
||||||
|
.credentialsProvider(credentials)
|
||||||
|
.serviceConfiguration(s3Config)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.presigner = S3Presigner.builder()
|
||||||
|
.endpointOverride(endpointUri)
|
||||||
|
.region(awsRegion)
|
||||||
|
.credentialsProvider(credentials)
|
||||||
|
.serviceConfiguration(s3Config)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String save(UUID bookId, String figureId, BufferedImage image) {
|
||||||
|
String key = "figures/" + bookId + "/" + figureId + ".png";
|
||||||
|
try {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "PNG", out);
|
||||||
|
byte[] bytes = out.toByteArray();
|
||||||
|
|
||||||
|
s3.putObject(
|
||||||
|
PutObjectRequest.builder().bucket(bucket).key(key)
|
||||||
|
.contentType("image/png").contentLength((long) bytes.length).build(),
|
||||||
|
RequestBody.fromBytes(bytes));
|
||||||
|
return key;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new RuntimeException("Failed to encode figure " + figureId, ex);
|
||||||
|
} catch (S3Exception ex) {
|
||||||
|
throw new RuntimeException("Failed to upload figure " + figureId + " to S3", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getBytes(String key) {
|
||||||
|
try {
|
||||||
|
return s3.getObjectAsBytes(
|
||||||
|
GetObjectRequest.builder().bucket(bucket).key(key).build()).asByteArray();
|
||||||
|
} catch (S3Exception ex) {
|
||||||
|
throw new RuntimeException("Failed to download figure from S3: " + key, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String presignedUrl(String key) {
|
||||||
|
GetObjectPresignRequest request = GetObjectPresignRequest.builder()
|
||||||
|
.signatureDuration(Duration.ofHours(1))
|
||||||
|
.getObjectRequest(r -> r.bucket(bucket).key(key))
|
||||||
|
.build();
|
||||||
|
return presigner.presignGetObject(request).url().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteAll(UUID bookId) {
|
||||||
|
String prefix = "figures/" + bookId + "/";
|
||||||
|
try {
|
||||||
|
List<ObjectIdentifier> toDelete = new ArrayList<>();
|
||||||
|
ListObjectsV2Request listRequest = ListObjectsV2Request.builder()
|
||||||
|
.bucket(bucket).prefix(prefix).build();
|
||||||
|
|
||||||
|
s3.listObjectsV2Paginator(listRequest).stream()
|
||||||
|
.flatMap(page -> page.contents().stream())
|
||||||
|
.map(S3Object::key)
|
||||||
|
.map(k -> ObjectIdentifier.builder().key(k).build())
|
||||||
|
.forEach(toDelete::add);
|
||||||
|
|
||||||
|
if (toDelete.isEmpty()) return;
|
||||||
|
|
||||||
|
s3.deleteObjects(DeleteObjectsRequest.builder()
|
||||||
|
.bucket(bucket)
|
||||||
|
.delete(Delete.builder().objects(toDelete).build())
|
||||||
|
.build());
|
||||||
|
log.info("Deleted {} figures from S3 for book {}", toDelete.size(), bookId);
|
||||||
|
} catch (S3Exception ex) {
|
||||||
|
log.warn("Could not fully delete figures for book {} from S3: {}", bookId, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,11 @@ app:
|
|||||||
auth:
|
auth:
|
||||||
password: ${APP_PASSWORD:changeme}
|
password: ${APP_PASSWORD:changeme}
|
||||||
figure-storage:
|
figure-storage:
|
||||||
base-path: ${FIGURE_STORAGE_PATH:./uploads}
|
endpoint: https://s3.immich-ad.ovh
|
||||||
|
region: garage
|
||||||
|
bucket: ${S3_BUCKET:aiteacher}
|
||||||
|
access-key-id: ${S3_ACCESS_KEY_ID}
|
||||||
|
secret-access-key: ${S3_SECRET_ACCESS_KEY}
|
||||||
min-image-size-px: 100
|
min-image-size-px: 100
|
||||||
embedding:
|
embedding:
|
||||||
batch-size: 20
|
batch-size: 20
|
||||||
|
|||||||
Reference in New Issue
Block a user