From 09b00d46fff035c8d99f3f160f52b3b34d0ef8c0 Mon Sep 17 00:00:00 2001 From: Helio-206 Date: Mon, 17 Aug 2026 23:01:49 +0100 Subject: [PATCH 1/2] Add S3-compatible question image storage --- docker-compose.yml | 63 ++++++ docs/IMAGE_STORAGE.md | 37 ++++ services/backend-api/pom.xml | 6 + .../kixi/config/StorageConfiguration.java | 38 ++++ .../kixi/config/StorageProperties.java | 104 +++++++++ .../kixi/model/QuestionImage.java | 5 +- .../kixi/service/QuestionImageService.java | 205 +++++++++++------- .../kixi/service/storage/ImageStorage.java | 12 + .../service/storage/LocalImageStorage.java | 85 ++++++++ .../kixi/service/storage/S3ImageStorage.java | 80 +++++++ .../service/storage/StorageException.java | 12 + .../kixi/service/storage/StoredObject.java | 4 + .../V19__add_question_image_storage_key.sql | 6 + .../service/QuestionImageServiceTest.java | 137 ++++++++++++ 14 files changed, 719 insertions(+), 75 deletions(-) create mode 100644 docs/IMAGE_STORAGE.md create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageConfiguration.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageProperties.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/ImageStorage.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/LocalImageStorage.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/S3ImageStorage.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StorageException.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StoredObject.java create mode 100644 services/backend-api/src/main/resources/db/migration/V19__add_question_image_storage_key.sql create mode 100644 services/backend-api/src/test/java/ao/creativemode/kixi/service/QuestionImageServiceTest.java diff --git a/docker-compose.yml b/docker-compose.yml index 5137356..2129f70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,18 @@ services: APP_JWT_EXPIRATION_MS: ${APP_JWT_EXPIRATION_MS:-86400000} APP_AUTH_GOOGLE_STATE_COOKIE_SECURE: ${GOOGLE_OAUTH_STATE_COOKIE_SECURE:-true} OCR_SERVICE_API_KEY: ${OCR_API_KEY:?Set OCR_API_KEY for backend-to-OCR authentication} + # Object storage. Keep local for the default development profile; + # use STORAGE_PROVIDER=s3 with --profile storage for MinIO. + STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local} + STORAGE_LOCAL_ROOT: ${STORAGE_LOCAL_ROOT:-/tmp/kixi/uploads} + STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-/uploads} + STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://minio:9000} + STORAGE_S3_REGION: ${STORAGE_S3_REGION:-us-east-1} + STORAGE_S3_BUCKET: ${STORAGE_S3_BUCKET:-kixi-images} + STORAGE_S3_ACCESS_KEY: ${STORAGE_S3_ACCESS_KEY:-kixi-minio} + STORAGE_S3_SECRET_KEY: ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret} + STORAGE_S3_PATH_STYLE_ACCESS: ${STORAGE_S3_PATH_STYLE_ACCESS:-true} + STORAGE_MAX_OBJECT_SIZE_BYTES: ${STORAGE_MAX_OBJECT_SIZE_BYTES:-20971520} ports: - "${BACKEND_PORT:-8080}:8080" depends_on: @@ -255,6 +267,53 @@ services: networks: - kixi-network + # =========================================================================== + # S3-compatible object storage (Optional Profile) + # =========================================================================== + # Start with STORAGE_PROVIDER=s3 docker-compose --profile storage up + minio: + image: minio/minio:RELEASE.2024-06-13T22-53-53Z + container_name: kixi-minio + profiles: + - storage + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${STORAGE_S3_ACCESS_KEY:-kixi-minio} + MINIO_ROOT_PASSWORD: ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret} + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - kixi-network + + minio-init: + image: minio/mc:RELEASE.2024-06-13T21-21-32Z + container_name: kixi-minio-init + profiles: + - storage + depends_on: + minio: + condition: service_healthy + entrypoint: /bin/sh + command: + - -c + - >- + mc alias set local http://minio:9000 + ${STORAGE_S3_ACCESS_KEY:-kixi-minio} + ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret} + && mc mb --ignore-existing local/${STORAGE_S3_BUCKET:-kixi-images} + && mc anonymous set download local/${STORAGE_S3_BUCKET:-kixi-images} + networks: + - kixi-network + # ============================================================================= # Networks # ============================================================================= @@ -282,3 +341,7 @@ volumes: # Redis data persistence redis_data: name: kixi-redis-data + + # MinIO object data for local integration runs + minio_data: + name: kixi-minio-data diff --git a/docs/IMAGE_STORAGE.md b/docs/IMAGE_STORAGE.md new file mode 100644 index 0000000..0ca50dd --- /dev/null +++ b/docs/IMAGE_STORAGE.md @@ -0,0 +1,37 @@ +# Image storage + +Question images are stored through the `ImageStorage` port. The backend supports: + +- `local` (default): writes below `storage.local-root` and preserves the existing + `/uploads` URL base for local development; +- `s3`: uses AWS S3 or any S3-compatible endpoint such as MinIO. + +The object key is generated by the backend (`questions/{questionId}/{uuid}.ext`). +The multipart filename is never used as a path component. Only JPEG, PNG and +WebP are accepted, and the default maximum object size is 20 MiB. The key is +stored in `question_images.storage_key`; legacy rows remain purgeable without a +storage delete because they predate this column. + +## MinIO local integration + +Run the optional profile with the S3 provider enabled: + +```bash +STORAGE_PROVIDER=s3 \ +STORAGE_PUBLIC_BASE_URL=http://localhost:9000/kixi-images \ +docker compose --profile storage up --build +``` + +The profile creates the `kixi-images` bucket and enables anonymous downloads for +local development only. Do not carry that bucket policy to production. In +production, configure a private bucket with a controlled CDN or signed delivery +endpoint through `STORAGE_PUBLIC_BASE_URL`. + +## OCR boundary + +OCR currently returns image metadata (`suggestedFilename`, `region`, and page) +but not the source bytes or a stable crop rectangle in its HTTP response. The +backend therefore does not pretend that metadata is an uploaded image. Automatic +crop/upload association remains the next contract change: OCR must return a +versioned region contract and the backend must retain the corresponding source +bytes before `QuestionImage` records can be created automatically. diff --git a/services/backend-api/pom.xml b/services/backend-api/pom.xml index c02e300..d2ee7d8 100644 --- a/services/backend-api/pom.xml +++ b/services/backend-api/pom.xml @@ -27,6 +27,12 @@ + + software.amazon.awssdk + s3 + 2.25.60 + + org.springframework.boot spring-boot-starter-webflux diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageConfiguration.java b/services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageConfiguration.java new file mode 100644 index 0000000..3046b37 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageConfiguration.java @@ -0,0 +1,38 @@ +package ao.creativemode.kixi.config; + +import java.net.URI; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +@Configuration(proxyBeanMethods = false) +public class StorageConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "s3") + S3Client s3Client(StorageProperties properties) { + if (properties.getS3AccessKey().isBlank() || properties.getS3SecretKey().isBlank()) { + throw new IllegalStateException("storage.s3-access-key and storage.s3-secret-key must be configured for S3 storage"); + } + + var builder = S3Client.builder() + .region(Region.of(properties.getS3Region())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getS3AccessKey(), properties.getS3SecretKey()))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(properties.isS3PathStyleAccess()) + .build()); + + if (!properties.getS3Endpoint().isBlank()) { + builder.endpointOverride(URI.create(properties.getS3Endpoint())); + } + return builder.build(); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageProperties.java b/services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageProperties.java new file mode 100644 index 0000000..8fbe179 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/config/StorageProperties.java @@ -0,0 +1,104 @@ +package ao.creativemode.kixi.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Object storage configuration. The default keeps local development compatible + * with the existing static upload directory; production should use S3. + */ +@Component +@ConfigurationProperties(prefix = "storage") +public class StorageProperties { + + private String provider = "local"; + private String localRoot = "services/backend-api/src/main/resources/static/uploads"; + private String publicBaseUrl = "/uploads"; + private String s3Endpoint = ""; + private String s3Region = "us-east-1"; + private String s3Bucket = "kixi-images"; + private String s3AccessKey = ""; + private String s3SecretKey = ""; + private boolean s3PathStyleAccess = true; + private long maxObjectSizeBytes = 20L * 1024 * 1024; + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getLocalRoot() { + return localRoot; + } + + public void setLocalRoot(String localRoot) { + this.localRoot = localRoot; + } + + public String getPublicBaseUrl() { + return publicBaseUrl; + } + + public void setPublicBaseUrl(String publicBaseUrl) { + this.publicBaseUrl = publicBaseUrl; + } + + public String getS3Endpoint() { + return s3Endpoint; + } + + public void setS3Endpoint(String s3Endpoint) { + this.s3Endpoint = s3Endpoint; + } + + public String getS3Region() { + return s3Region; + } + + public void setS3Region(String s3Region) { + this.s3Region = s3Region; + } + + public String getS3Bucket() { + return s3Bucket; + } + + public void setS3Bucket(String s3Bucket) { + this.s3Bucket = s3Bucket; + } + + public String getS3AccessKey() { + return s3AccessKey; + } + + public void setS3AccessKey(String s3AccessKey) { + this.s3AccessKey = s3AccessKey; + } + + public String getS3SecretKey() { + return s3SecretKey; + } + + public void setS3SecretKey(String s3SecretKey) { + this.s3SecretKey = s3SecretKey; + } + + public boolean isS3PathStyleAccess() { + return s3PathStyleAccess; + } + + public void setS3PathStyleAccess(boolean s3PathStyleAccess) { + this.s3PathStyleAccess = s3PathStyleAccess; + } + + public long getMaxObjectSizeBytes() { + return maxObjectSizeBytes; + } + + public void setMaxObjectSizeBytes(long maxObjectSizeBytes) { + this.maxObjectSizeBytes = maxObjectSizeBytes; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java b/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java index c73a61a..c754001 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/model/QuestionImage.java @@ -21,6 +21,9 @@ public class QuestionImage { @Column("image_url") private String imageUrl; + @Column("storage_key") + private String storageKey; + @Column("caption") private String caption; @@ -52,4 +55,4 @@ public void restore() { public boolean isDeleted() { return deletedAt != null; } -} \ No newline at end of file +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java index e98cd55..560198d 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/QuestionImageService.java @@ -1,69 +1,62 @@ package ao.creativemode.kixi.service; +import java.time.LocalDateTime; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.UUID; + +import javax.imageio.ImageIO; + import ao.creativemode.kixi.common.exception.ApiException; +import ao.creativemode.kixi.config.StorageProperties; import ao.creativemode.kixi.dto.questionimage.QuestionImageRequest; import ao.creativemode.kixi.dto.questionimage.QuestionImageResponse; import ao.creativemode.kixi.model.QuestionImage; import ao.creativemode.kixi.repository.QuestionImageRepository; +import ao.creativemode.kixi.service.storage.ImageStorage; +import ao.creativemode.kixi.service.storage.StoredObject; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDateTime; -import java.util.UUID; - @Service public class QuestionImageService { + private static final Logger log = LoggerFactory.getLogger(QuestionImageService.class); + private final QuestionImageRepository repository; - - /** - * Physical path pointing to the static resources folder for Maven projects - */ - private final Path root = Paths.get("services/backend-api/src/main/resources/static/uploads/questions"); + private final ImageStorage storage; + private final StorageProperties storageProperties; - public QuestionImageService(QuestionImageRepository repository) { + public QuestionImageService( + QuestionImageRepository repository, + ImageStorage storage, + StorageProperties storageProperties) { this.repository = repository; - try { - // Ensure the physical directory exists on service startup - Files.createDirectories(root); - } catch (IOException e) { - throw new RuntimeException("Could not initialize folder for upload!"); - } + this.storage = storage; + this.storageProperties = storageProperties; } - /** - * Retrieves all active (non-deleted) question images - */ public Flux findAllActive() { - return repository.findAllByDeletedAtIsNull() - .map(this::toResponse); + return repository.findAllByDeletedAtIsNull().map(this::toResponse); } - /** - * Retrieves all soft-deleted question images - */ public Flux findAllDeleted() { - return repository.findAllByDeletedAtIsNotNull() - .map(this::toResponse); + return repository.findAllByDeletedAtIsNotNull().map(this::toResponse); } - /** - * Finds all images associated with a specific question that have not been deleted - */ public Flux findByQuestionId(Long questionId) { return repository.findByQuestionIdAndDeletedAtIsNullOrderByOrderIndexAsc(questionId) .map(this::toResponse); } - /** - * Retrieves a single active question image by its ID - */ public Mono findByIdActive(Long id) { return repository.findByIdAndDeletedAtIsNull(id) .switchIfEmpty(Mono.error(ApiException.notFound("Question image not found"))) @@ -71,35 +64,69 @@ public Mono findByIdActive(Long id) { } /** - * Creates a new QuestionImage by saving the physical file to the resources folder - * and generating its public URL + * Stores a validated raster image through the configured storage adapter. + * The client filename is intentionally not used as an object key. */ - public Mono createWithFile(QuestionImageRequest dto, Mono filePartMono) { + public Mono createWithFile( + QuestionImageRequest dto, + Mono filePartMono) { return filePartMono.flatMap(filePart -> { - // Generate a unique filename to prevent overwriting - String filename = UUID.randomUUID() + "-" + filePart.filename(); - Path targetPath = this.root.resolve(filename); - - // Transfer the incoming file bytes to the physical target path - return filePart.transferTo(targetPath) - .then(Mono.defer(() -> { - QuestionImage entity = new QuestionImage(); - entity.setQuestionId(dto.questionId()); - - // Set the public URL path (mapped via WebFlux static resources) - entity.setImageUrl("/uploads/questions/" + filename); - entity.setCaption(dto.caption()); - entity.setOrderIndex(dto.orderIndex() != null ? dto.orderIndex() : 0); - entity.setDeletedAt(null); - - return repository.save(entity); - })); + MediaType contentType = filePart.headers().getContentType(); + if (!isSupportedImage(contentType)) { + return Mono.error(ApiException.badRequest( + "Only JPEG, PNG and WebP images are supported")); + } + + long contentLength = filePart.headers().getContentLength(); + if (contentLength > storageProperties.getMaxObjectSizeBytes()) { + return Mono.error(ApiException.badRequest("Image exceeds the configured size limit")); + } + + String key = "questions/" + dto.questionId() + "/" + + UUID.randomUUID() + extension(contentType); + + return DataBufferUtils.join(filePart.content(), maxSize()) + .map(buffer -> { + try { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } finally { + DataBufferUtils.release(buffer); + } + }) + .onErrorMap(DataBufferLimitException.class, + error -> ApiException.badRequest("Image exceeds the configured size limit")) + .switchIfEmpty(Mono.error(ApiException.badRequest("Image content is required"))) + .flatMap(bytes -> { + if (!isValidRaster(contentType, bytes)) { + return Mono.error(ApiException.badRequest("Image content does not match its media type")); + } + return storage.put(key, contentType, bytes) + .flatMap(stored -> saveEntity(dto, stored)); + }); }).map(this::toResponse); } - /** - * Updates metadata (caption, order) for an existing active question image - */ + private Mono saveEntity(QuestionImageRequest dto, StoredObject stored) { + QuestionImage entity = new QuestionImage(); + entity.setQuestionId(dto.questionId()); + entity.setImageUrl(stored.publicUrl()); + entity.setStorageKey(stored.key()); + entity.setCaption(dto.caption()); + entity.setOrderIndex(dto.orderIndex() != null ? dto.orderIndex() : 0); + entity.setDeletedAt(null); + + return repository.save(entity) + .onErrorResume(error -> storage.delete(stored.key()) + .onErrorResume(cleanupError -> { + log.error("Image cleanup failed after database error: key={}, errorType={}", + stored.key(), cleanupError.getClass().getSimpleName()); + return Mono.empty(); + }) + .then(Mono.error(error))); + } + public Mono update(Long id, QuestionImageRequest dto) { return repository.findByIdAndDeletedAtIsNull(id) .switchIfEmpty(Mono.error(ApiException.notFound("Question image not found"))) @@ -107,15 +134,11 @@ public Mono update(Long id, QuestionImageRequest dto) { entity.setCaption(dto.caption() != null ? dto.caption() : entity.getCaption()); entity.setOrderIndex(dto.orderIndex() != null ? dto.orderIndex() : entity.getOrderIndex()); entity.setUpdatedAt(LocalDateTime.now()); - return repository.save(entity); }) .map(this::toResponse); } - /** - * Marks a question image as deleted (Soft Delete) - */ public Mono softDelete(Long id) { return repository.findByIdAndDeletedAtIsNull(id) .switchIfEmpty(Mono.error(ApiException.notFound("Question image not found"))) @@ -126,9 +149,6 @@ public Mono softDelete(Long id) { .then(); } - /** - * Restores a previously soft-deleted question image - */ public Mono restore(Long id) { return repository.findByIdAndDeletedAtIsNotNull(id) .switchIfEmpty(Mono.error(ApiException.badRequest("Question image is not deleted"))) @@ -139,19 +159,56 @@ public Mono restore(Long id) { .then(); } - /** - * Permanently removes a question image from the database - */ public Mono hardDelete(Long id) { return repository.findByIdAndDeletedAtIsNotNull(id) .switchIfEmpty(Mono.error(ApiException.badRequest("Only deleted images can be permanently removed"))) - .flatMap(repository::delete) + .flatMap(entity -> storage.delete(entity.getStorageKey()) + .then(repository.delete(entity))) .then(); } - /** - * Converts the internal Entity to a Response DTO - */ + private boolean isSupportedImage(MediaType contentType) { + if (contentType == null || !"image".equalsIgnoreCase(contentType.getType())) { + return false; + } + String subtype = contentType.getSubtype(); + return "jpeg".equalsIgnoreCase(subtype) + || "png".equalsIgnoreCase(subtype) + || "webp".equalsIgnoreCase(subtype); + } + + private boolean isValidRaster(MediaType contentType, byte[] bytes) { + if ("png".equalsIgnoreCase(contentType.getSubtype()) + || "jpeg".equalsIgnoreCase(contentType.getSubtype())) { + try { + return ImageIO.read(new ByteArrayInputStream(bytes)) != null; + } catch (IOException ex) { + return false; + } + } + return bytes.length >= 12 + && bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F' + && bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P'; + } + + private int maxSize() { + long max = storageProperties.getMaxObjectSizeBytes(); + if (max < 1 || max > Integer.MAX_VALUE) { + throw new IllegalStateException("storage.max-object-size-bytes must be between 1 and 2147483647"); + } + return (int) max; + } + + private String extension(MediaType contentType) { + if ("png".equalsIgnoreCase(contentType.getSubtype())) { + return ".png"; + } + if ("webp".equalsIgnoreCase(contentType.getSubtype())) { + return ".webp"; + } + return ".jpg"; + } + private QuestionImageResponse toResponse(QuestionImage entity) { return new QuestionImageResponse( entity.getId(), @@ -163,4 +220,4 @@ private QuestionImageResponse toResponse(QuestionImage entity) { entity.getUpdatedAt(), entity.getDeletedAt()); } -} \ No newline at end of file +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/ImageStorage.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/ImageStorage.java new file mode 100644 index 0000000..5a6a27b --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/ImageStorage.java @@ -0,0 +1,12 @@ +package ao.creativemode.kixi.service.storage; + +import org.springframework.http.MediaType; + +import reactor.core.publisher.Mono; + +public interface ImageStorage { + + Mono put(String key, MediaType contentType, byte[] content); + + Mono delete(String key); +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/LocalImageStorage.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/LocalImageStorage.java new file mode 100644 index 0000000..9673ddc --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/LocalImageStorage.java @@ -0,0 +1,85 @@ +package ao.creativemode.kixi.service.storage; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +import ao.creativemode.kixi.config.StorageProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@Service +@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "local", matchIfMissing = true) +public class LocalImageStorage implements ImageStorage { + + private final StorageProperties properties; + private final Path root; + + public LocalImageStorage(StorageProperties properties) { + this.properties = properties; + this.root = Paths.get(properties.getLocalRoot()).toAbsolutePath().normalize(); + } + + @Override + public Mono put(String key, MediaType contentType, byte[] content) { + Path target = resolveKey(key); + if (content.length > maxSize()) { + return Mono.error(new StorageException("Image exceeds the configured size limit")); + } + return Mono.fromCallable(() -> { + try { + Files.createDirectories(target.getParent()); + Files.write(target, content, StandardOpenOption.CREATE_NEW); + return new StoredObject(key, publicUrl(key)); + } catch (IOException ex) { + throw new StorageException("Could not persist image object", ex); + } + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Mono delete(String key) { + if (key == null || key.isBlank()) { + return Mono.empty(); + } + Path target = resolveKey(key); + return Mono.fromRunnable(() -> { + try { + Files.deleteIfExists(target); + } catch (IOException ex) { + throw new StorageException("Could not delete image object", ex); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } + + private Path resolveKey(String key) { + Path resolved = root.resolve(key).normalize(); + if (!resolved.startsWith(root)) { + throw new StorageException("Invalid image storage key"); + } + return resolved; + } + + private int maxSize() { + long max = properties.getMaxObjectSizeBytes(); + if (max < 1 || max > Integer.MAX_VALUE) { + throw new IllegalStateException("storage.max-object-size-bytes must be between 1 and 2147483647"); + } + return (int) max; + } + + private String publicUrl(String key) { + String base = properties.getPublicBaseUrl(); + if (base == null || base.isBlank()) { + throw new StorageException("storage.public-base-url must be configured for local storage"); + } + return base.replaceAll("/$", "") + "/" + key; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/S3ImageStorage.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/S3ImageStorage.java new file mode 100644 index 0000000..81d9eb1 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/S3ImageStorage.java @@ -0,0 +1,80 @@ +package ao.creativemode.kixi.service.storage; + +import ao.creativemode.kixi.config.StorageProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Service +@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "s3") +public class S3ImageStorage implements ImageStorage { + + private final S3Client client; + private final StorageProperties properties; + + public S3ImageStorage(S3Client client, StorageProperties properties) { + this.client = client; + this.properties = properties; + } + + @Override + public Mono put(String key, MediaType contentType, byte[] content) { + if (content.length > maxSize()) { + return Mono.error(new StorageException("Image exceeds the configured size limit")); + } + return Mono.fromCallable(() -> { + client.putObject( + PutObjectRequest.builder() + .bucket(properties.getS3Bucket()) + .key(key) + .contentType(contentType.toString()) + .contentLength((long) content.length) + .build(), + RequestBody.fromBytes(content)); + return new StoredObject(key, publicUrl(key)); + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Mono delete(String key) { + if (key == null || key.isBlank()) { + return Mono.empty(); + } + return Mono.fromRunnable(() -> client.deleteObject(DeleteObjectRequest.builder() + .bucket(properties.getS3Bucket()) + .key(key) + .build())) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + private int maxSize() { + long max = properties.getMaxObjectSizeBytes(); + if (max < 1 || max > Integer.MAX_VALUE) { + throw new IllegalStateException("storage.max-object-size-bytes must be between 1 and 2147483647"); + } + return (int) max; + } + + private String publicUrl(String key) { + String base = properties.getPublicBaseUrl(); + if (base == null || base.isBlank()) { + String endpoint = properties.getS3Endpoint(); + if (endpoint == null || endpoint.isBlank()) { + endpoint = "https://" + properties.getS3Bucket() + ".s3." + properties.getS3Region() + ".amazonaws.com"; + } else { + endpoint = endpoint.replaceAll("/$", "") + "/" + properties.getS3Bucket(); + } + base = endpoint; + } + return base.replaceAll("/$", "") + "/" + key; + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StorageException.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StorageException.java new file mode 100644 index 0000000..74f8526 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StorageException.java @@ -0,0 +1,12 @@ +package ao.creativemode.kixi.service.storage; + +public class StorageException extends RuntimeException { + + public StorageException(String message) { + super(message); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StoredObject.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StoredObject.java new file mode 100644 index 0000000..cacb8e5 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/storage/StoredObject.java @@ -0,0 +1,4 @@ +package ao.creativemode.kixi.service.storage; + +public record StoredObject(String key, String publicUrl) { +} diff --git a/services/backend-api/src/main/resources/db/migration/V19__add_question_image_storage_key.sql b/services/backend-api/src/main/resources/db/migration/V19__add_question_image_storage_key.sql new file mode 100644 index 0000000..31ab63f --- /dev/null +++ b/services/backend-api/src/main/resources/db/migration/V19__add_question_image_storage_key.sql @@ -0,0 +1,6 @@ +ALTER TABLE question_images + ADD COLUMN IF NOT EXISTS storage_key TEXT; + +CREATE INDEX IF NOT EXISTS idx_question_images_storage_key + ON question_images(storage_key) + WHERE storage_key IS NOT NULL; diff --git a/services/backend-api/src/test/java/ao/creativemode/kixi/service/QuestionImageServiceTest.java b/services/backend-api/src/test/java/ao/creativemode/kixi/service/QuestionImageServiceTest.java new file mode 100644 index 0000000..79eebda --- /dev/null +++ b/services/backend-api/src/test/java/ao/creativemode/kixi/service/QuestionImageServiceTest.java @@ -0,0 +1,137 @@ +package ao.creativemode.kixi.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; + +import javax.imageio.ImageIO; + +import ao.creativemode.kixi.config.StorageProperties; +import ao.creativemode.kixi.dto.questionimage.QuestionImageRequest; +import ao.creativemode.kixi.model.QuestionImage; +import ao.creativemode.kixi.repository.QuestionImageRepository; +import ao.creativemode.kixi.service.storage.ImageStorage; +import ao.creativemode.kixi.service.storage.StoredObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.mockito.Mockito.mock; + +class QuestionImageServiceTest { + + private QuestionImageRepository repository; + private ImageStorage storage; + private StorageProperties properties; + private QuestionImageService service; + + @BeforeEach + void setUp() { + repository = mock(QuestionImageRepository.class); + storage = mock(ImageStorage.class); + properties = new StorageProperties(); + service = new QuestionImageService(repository, storage, properties); + } + + @Test + void storesRasterImageWithGeneratedKeyAndPersistsStorageKey() { + FilePart file = filePart(MediaType.IMAGE_PNG, "../../unsafe.png", 3L); + StoredObject stored = new StoredObject("questions/7/object.png", "/uploads/questions/7/object.png"); + AtomicReference persisted = new AtomicReference<>(); + when(storage.put(anyString(), any(), any())).thenReturn(Mono.just(stored)); + when(repository.save(any(QuestionImage.class))).thenAnswer(invocation -> { + QuestionImage entity = invocation.getArgument(0); + entity.setId(11L); + persisted.set(entity); + return Mono.just(entity); + }); + + StepVerifier.create(service.createWithFile( + new QuestionImageRequest(7L, "Figura", 2), Mono.just(file))) + .assertNext(response -> assertThat(response.imageUrl()).isEqualTo(stored.publicUrl())) + .verifyComplete(); + + assertThat(persisted.get().getStorageKey()).isEqualTo(stored.key()); + verify(storage).put(anyString(), any(), any()); + } + + @Test + void rejectsNonRasterUploadBeforeStorage() { + FilePart file = filePart(MediaType.APPLICATION_PDF, "exam.pdf", 3L); + + StepVerifier.create(service.createWithFile( + new QuestionImageRequest(7L, null, null), Mono.just(file))) + .expectErrorMessage("Only JPEG, PNG and WebP images are supported") + .verify(); + + verify(storage, never()).put(anyString(), any(), any()); + verify(repository, never()).save(any()); + } + + @Test + void rejectsRasterMimeTypeWithNonImageBytes() { + FilePart file = filePart(MediaType.IMAGE_PNG, "spoofed.png", 9L, new byte[] {1, 2, 3}); + + StepVerifier.create(service.createWithFile( + new QuestionImageRequest(7L, null, null), Mono.just(file))) + .expectErrorMessage("Image content does not match its media type") + .verify(); + + verify(storage, never()).put(anyString(), any(), any()); + verify(repository, never()).save(any()); + } + + @Test + void purgesStorageObjectBeforeDeletingDatabaseRow() { + QuestionImage entity = new QuestionImage(); + entity.setId(11L); + entity.setStorageKey("questions/7/object.png"); + when(repository.findByIdAndDeletedAtIsNotNull(11L)).thenReturn(Mono.just(entity)); + when(storage.delete(entity.getStorageKey())).thenReturn(Mono.empty()); + when(repository.delete(entity)).thenReturn(Mono.empty()); + + StepVerifier.create(service.hardDelete(11L)).verifyComplete(); + + verify(storage).delete(entity.getStorageKey()); + verify(repository).delete(entity); + } + + private FilePart filePart(MediaType type, String filename, long length) { + return filePart(type, filename, length, pngBytes()); + } + + private FilePart filePart(MediaType type, String filename, long length, byte[] bytes) { + FilePart file = mock(FilePart.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(type); + headers.setContentLength(length); + when(file.headers()).thenReturn(headers); + when(file.filename()).thenReturn(filename); + when(file.content()).thenReturn(Flux.just(new org.springframework.core.io.buffer.DefaultDataBufferFactory().wrap(bytes))); + return file; + } + + private byte[] pngBytes() { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), "png", output); + return output.toByteArray(); + } catch (IOException ex) { + throw new AssertionError(ex); + } + } +} From 555af3dece75f93dffca68ea2b9ce47248ec8897 Mon Sep 17 00:00:00 2001 From: Helio-206 Date: Tue, 18 Aug 2026 15:19:54 +0100 Subject: [PATCH 2/2] Complete OCR region persistence contract --- docs/IMAGE_STORAGE.md | 16 +- .../kixi/client/OcrServiceClient.java | 47 ++++ .../kixi/client/OcrUploadedFile.java | 22 ++ .../kixi/controller/OcrController.java | 62 +++++- .../kixi/dto/ocr/ExamExtractionResponse.java | 22 +- .../kixi/dto/ocr/OcrResponse.java | 12 +- .../service/OcrImageAssociationService.java | 201 ++++++++++++++++++ .../kixi/service/OcrPersistenceService.java | 27 ++- .../OcrImageAssociationServiceTest.java | 99 +++++++++ .../service/OcrPersistenceServiceTest.java | 18 +- services/ocr-service/app/api/routes.py | 7 +- services/ocr-service/app/ocr/engine.py | 22 +- .../ocr-service/app/ocr/postprocessing.py | 78 ++++++- .../tests/test_postprocessing_edges.py | 28 +++ 14 files changed, 626 insertions(+), 35 deletions(-) create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrUploadedFile.java create mode 100644 services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrImageAssociationService.java create mode 100644 services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrImageAssociationServiceTest.java diff --git a/docs/IMAGE_STORAGE.md b/docs/IMAGE_STORAGE.md index 0ca50dd..3e3583e 100644 --- a/docs/IMAGE_STORAGE.md +++ b/docs/IMAGE_STORAGE.md @@ -29,9 +29,13 @@ endpoint through `STORAGE_PUBLIC_BASE_URL`. ## OCR boundary -OCR currently returns image metadata (`suggestedFilename`, `region`, and page) -but not the source bytes or a stable crop rectangle in its HTTP response. The -backend therefore does not pretend that metadata is an uploaded image. Automatic -crop/upload association remains the next contract change: OCR must return a -versioned region contract and the backend must retain the corresponding source -bytes before `QuestionImage` records can be created automatically. +OCR returns a versioned region contract (`contractVersion: 1`) with page index, +source-file index, source dimensions and a bounded crop rectangle. The +persistence endpoint retains the submitted raster bytes, crops `questao_N` +regions, stores the PNG through the same `ImageStorage` port and associates it +with the persisted question. + +PDF sources and `cabecalho`/`rodape` regions remain metadata-only in this slice: +the OCR service renders PDF pages internally and `question_images` requires a +question association. A future statement-asset contract must define those +cases before they are persisted automatically. diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java b/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java index 55768e7..1f1f266 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java @@ -139,6 +139,53 @@ public Mono extractText(List files) { .doOnError(error -> log.error("OCR request failed: type={}", error.getClass().getSimpleName())); } + /** + * Extract text from bytes retained by the persistence flow. This variant + * avoids replaying a consumed multipart stream when OCR regions are later + * associated with their source page. + */ + public Mono extractTextFromUploadedFiles(List files) { + if (files == null || files.isEmpty()) { + return Mono.error(new IllegalArgumentException("At least one file is required")); + } + + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + for (OcrUploadedFile file : files) { + builder.part("images", file.content()) + .filename(file.filename()) + .contentType(file.contentType() != null + ? file.contentType() + : getContentType(file.filename())); + } + + log.info("Sending retained OCR request: {} file(s)", files.size()); + return webClient.post() + .uri("/ocr/v1/extract") + .headers(this::applyAuthentication) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> + response.bodyToMono(String.class) + .flatMap(body -> Mono.error(new OcrClientException( + "OCR request failed: " + body, + response.statusCode().value())))) + .onStatus(HttpStatusCode::is5xxServerError, response -> + response.bodyToMono(String.class) + .flatMap(body -> Mono.error(new OcrServerException( + "OCR service error: " + body, + response.statusCode().value())))) + .bodyToMono(OcrResponse.class) + .timeout(timeout) + .retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1)) + .filter(this::isRetryable)) + .doOnSuccess(response -> log.info( + "OCR retained request successful: requestId={}, status={}", + response.requestId(), response.status())) + .doOnError(error -> log.error("OCR retained request failed: type={}", + error.getClass().getSimpleName())); + } + /** * Extract text from raw image bytes. * diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrUploadedFile.java b/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrUploadedFile.java new file mode 100644 index 0000000..ace56af --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrUploadedFile.java @@ -0,0 +1,22 @@ +package ao.creativemode.kixi.client; + +import org.springframework.http.MediaType; + +/** + * Immutable upload retained for OCR persistence. Keeping the bytes here lets + * the backend associate OCR regions with the exact source page it submitted. + */ +public record OcrUploadedFile( + String filename, + MediaType contentType, + byte[] content) { + + public OcrUploadedFile { + if (filename == null || filename.isBlank()) { + throw new IllegalArgumentException("Filename is required"); + } + if (content == null || content.length == 0) { + throw new IllegalArgumentException("File content cannot be empty"); + } + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java index d8d9afc..70707cc 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/controller/OcrController.java @@ -1,6 +1,7 @@ package ao.creativemode.kixi.controller; import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.client.OcrUploadedFile; import ao.creativemode.kixi.common.exception.ApiException; import ao.creativemode.kixi.dto.ocr.ExamExtractionResponse; import ao.creativemode.kixi.dto.ocr.OcrResponse; @@ -16,6 +17,8 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.codec.multipart.FilePart; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -52,6 +55,7 @@ public class OcrController { ); private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + private static final long MAX_PERSIST_SOURCE_SIZE = 50 * 1024 * 1024; private static final int MAX_FILES = 10; private final OcrServiceClient ocrServiceClient; @@ -333,11 +337,12 @@ > extractAndPersist( fileList.size() ); - // Process and persist - return ocrPersistenceService.processAndPersist( - fileList, - createdBy - ); + // Retain the exact source bytes for OCR-region association. + return bufferFilesForPersistence(fileList) + .flatMap(uploadedFiles -> ocrPersistenceService.processAndPersist( + uploadedFiles, + createdBy + )); })) .map(result -> { StatementWithRelationsResponse response = @@ -387,7 +392,13 @@ > extractAndPersist( new ImageToUploadInfo( img.suggestedFilename(), img.description(), - img.region() + img.region(), + img.pageIndex(), + img.bbox(), + img.sourceWidth(), + img.sourceHeight(), + img.sourceFileIndex(), + img.contractVersion() ) ) .toList() @@ -409,6 +420,37 @@ > extractAndPersist( ); } + private Mono> bufferFilesForPersistence(List files) { + return Flux.fromIterable(files) + .concatMap(file -> DataBufferUtils.join(file.content(), (int) MAX_FILE_SIZE) + .map(buffer -> { + try { + byte[] content = new byte[buffer.readableByteCount()]; + buffer.read(content); + return new OcrUploadedFile( + file.filename(), + file.headers().getContentType(), + content + ); + } finally { + DataBufferUtils.release(buffer); + } + })) + .collectList() + .flatMap(uploadedFiles -> { + long totalBytes = uploadedFiles.stream() + .mapToLong(file -> file.content().length) + .sum(); + if (totalBytes > MAX_PERSIST_SOURCE_SIZE) { + return Mono.error(ApiException.badRequest( + "Persisted OCR input exceeds the 50 MB aggregate limit")); + } + return Mono.just(uploadedFiles); + }) + .onErrorMap(DataBufferLimitException.class, + error -> ApiException.badRequest("File exceeds the 20 MB limit")); + } + /** * Check OCR service health. * @@ -593,6 +635,12 @@ public record ClassInfo(Long id, Integer grade, String code) {} public record ImageToUploadInfo( String suggestedFilename, String description, - String region + String region, + Integer pageIndex, + List bbox, + Integer sourceWidth, + Integer sourceHeight, + Integer sourceFileIndex, + Integer contractVersion ) {} } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java index 906f247..dbff01e 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/ExamExtractionResponse.java @@ -196,7 +196,19 @@ public record ImageToUploadData( String description, - String region + String region, + + @JsonProperty("page_index") Integer pageIndex, + + List bbox, + + @JsonProperty("source_width") Integer sourceWidth, + + @JsonProperty("source_height") Integer sourceHeight, + + @JsonProperty("source_file_index") Integer sourceFileIndex, + + @JsonProperty("contract_version") Integer contractVersion ) { /** * Create from ImageToUpload. @@ -207,7 +219,13 @@ public static ImageToUploadData fromImageToUpload(OcrResponse.ImageToUpload img) return new ImageToUploadData( img.suggestedFilename(), img.description(), - img.region() + img.region(), + img.pageIndex(), + img.bbox(), + img.sourceWidth(), + img.sourceHeight(), + img.sourceFileIndex(), + img.contractVersion() ); } diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java index db79497..fa1a5eb 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/dto/ocr/OcrResponse.java @@ -276,7 +276,17 @@ public record ImageToUpload( String region, - @JsonProperty("pageIndex") Integer pageIndex + @JsonProperty("pageIndex") Integer pageIndex, + + List bbox, + + @JsonProperty("sourceWidth") Integer sourceWidth, + + @JsonProperty("sourceHeight") Integer sourceHeight, + + @JsonProperty("contractVersion") Integer contractVersion, + + @JsonProperty("sourceFileIndex") Integer sourceFileIndex ) { /** * Check if this is a header/logo image. diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrImageAssociationService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrImageAssociationService.java new file mode 100644 index 0000000..2b72749 --- /dev/null +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrImageAssociationService.java @@ -0,0 +1,201 @@ +package ao.creativemode.kixi.service; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.imageio.ImageIO; + +import ao.creativemode.kixi.client.OcrUploadedFile; +import ao.creativemode.kixi.dto.ocr.OcrResponse; +import ao.creativemode.kixi.model.Question; +import ao.creativemode.kixi.model.QuestionImage; +import ao.creativemode.kixi.repository.QuestionImageRepository; +import ao.creativemode.kixi.service.storage.ImageStorage; +import ao.creativemode.kixi.service.storage.StoredObject; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Materializes OCR image regions after the questions have been persisted. + * Only raster source uploads are eligible: PDF page rendering remains owned by + * the OCR service and cannot be safely reconstructed from the original bytes. + */ +@Service +public class OcrImageAssociationService { + + private static final int REGION_CONTRACT_VERSION = 1; + private static final int MAX_CROP_BYTES = 10 * 1024 * 1024; + + private final QuestionImageRepository repository; + private final ImageStorage storage; + + public OcrImageAssociationService( + QuestionImageRepository repository, + ImageStorage storage) { + this.repository = repository; + this.storage = storage; + } + + public Mono> persistQuestionImages( + List questions, + List regions, + List sourceFiles) { + if (questions == null || questions.isEmpty() || regions == null || regions.isEmpty()) { + return Mono.just(List.of()); + } + + Map questionsByNumber = questions.stream() + .filter(question -> question.getNumber() != null) + .collect(Collectors.toMap( + Question::getNumber, + Function.identity(), + (first, ignored) -> first)); + + List candidates = regions.stream() + .filter(region -> region != null && region.isQuestionImage()) + .map(region -> candidate(region, questionsByNumber, sourceFiles)) + .flatMap(Optional::stream) + .toList(); + + if (candidates.isEmpty()) { + return Mono.just(List.of()); + } + + List createdObjects = new ArrayList<>(); + return Flux.fromIterable(candidates) + .concatMap(candidate -> storage.put( + candidate.key(), + MediaType.IMAGE_PNG, + candidate.pngBytes()) + .doOnNext(createdObjects::add) + .flatMap(stored -> saveEntity(candidate, stored))) + .collectList() + .onErrorResume(error -> cleanup(createdObjects).then(Mono.error(error))); + } + + private Optional candidate( + OcrResponse.ImageToUpload region, + Map questionsByNumber, + List sourceFiles) { + Integer questionNumber = parseQuestionNumber(region.getQuestionNumber()); + if (questionNumber == null || region.contractVersion() == null + || region.contractVersion() != REGION_CONTRACT_VERSION + || region.bbox() == null || region.bbox().size() != 4 + || sourceFiles == null || region.sourceFileIndex() == null + || region.sourceFileIndex() < 0 + || region.sourceFileIndex() >= sourceFiles.size() + || !isRaster(sourceFiles.get(region.sourceFileIndex()))) { + return Optional.empty(); + } + + Question question = questionsByNumber.get(questionNumber); + if (question == null) { + return Optional.empty(); + } + + byte[] png = cropToPng(sourceFiles.get(region.sourceFileIndex()), region); + if (png == null || png.length == 0 || png.length > MAX_CROP_BYTES) { + return Optional.empty(); + } + + return Optional.of(new RegionCandidate( + question, + region.description(), + "questions/" + question.getId() + "/ocr-" + java.util.UUID.randomUUID() + ".png", + png)); + } + + private Mono saveEntity(RegionCandidate candidate, StoredObject stored) { + QuestionImage entity = new QuestionImage(); + entity.setQuestionId(candidate.question().getId()); + entity.setImageUrl(stored.publicUrl()); + entity.setStorageKey(stored.key()); + entity.setCaption(candidate.description()); + entity.setOrderIndex(0); + + return repository.save(entity) + .onErrorResume(error -> storage.delete(stored.key()).then(Mono.error(error))); + } + + private Mono cleanup(List objects) { + return Flux.fromIterable(objects) + .concatMap(object -> storage.delete(object.key()).onErrorResume(ignored -> Mono.empty())) + .then(); + } + + private byte[] cropToPng(OcrUploadedFile source, OcrResponse.ImageToUpload region) { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(source.content())); + if (image == null) { + return null; + } + + int sourceWidth = region.sourceWidth() == null ? image.getWidth() : region.sourceWidth(); + int sourceHeight = region.sourceHeight() == null ? image.getHeight() : region.sourceHeight(); + if (sourceWidth < 1 || sourceHeight < 1) { + return null; + } + + double scaleX = image.getWidth() / (double) sourceWidth; + double scaleY = image.getHeight() / (double) sourceHeight; + List bbox = region.bbox(); + int x1 = clamp((int) Math.floor(bbox.get(0) * scaleX), 0, image.getWidth() - 1); + int y1 = clamp((int) Math.floor(bbox.get(1) * scaleY), 0, image.getHeight() - 1); + int x2 = clamp((int) Math.ceil(bbox.get(2) * scaleX), x1 + 1, image.getWidth()); + int y2 = clamp((int) Math.ceil(bbox.get(3) * scaleY), y1 + 1, image.getHeight()); + + BufferedImage cropped = image.getSubimage(x1, y1, x2 - x1, y2 - y1); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(cropped, "png", output)) { + return null; + } + return output.toByteArray(); + } catch (IOException | RuntimeException error) { + return null; + } + } + + private boolean isRaster(OcrUploadedFile file) { + try { + return ImageIO.read(new ByteArrayInputStream(file.content())) != null; + } catch (IOException | RuntimeException error) { + return false; + } + } + + private Integer parseQuestionNumber(String value) { + if (value == null || value.isBlank()) { + return null; + } + String digits = value.replaceFirst("^(\\d+).*$", "$1"); + try { + return digits.equals(value) || digits.matches("\\d+") + ? Integer.valueOf(digits) + : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private int clamp(int value, int min, int max) { + return Math.max(min, Math.min(value, max)); + } + + private record RegionCandidate( + Question question, + String description, + String key, + byte[] pngBytes) { + } +} diff --git a/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java b/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java index 7dc09c7..0ec7c95 100644 --- a/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java +++ b/services/backend-api/src/main/java/ao/creativemode/kixi/service/OcrPersistenceService.java @@ -1,6 +1,7 @@ package ao.creativemode.kixi.service; import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.client.OcrUploadedFile; import ao.creativemode.kixi.common.exception.ApiException; import ao.creativemode.kixi.dto.ocr.OcrResponse; import ao.creativemode.kixi.dto.ocr.OcrResponse.ExtractedOption; @@ -24,7 +25,6 @@ import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.codec.multipart.FilePart; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; @@ -63,6 +63,7 @@ public class OcrPersistenceService { private final CourseRepository courseRepository; private final SubjectRepository subjectRepository; private final ClassRepository classRepository; + private final OcrImageAssociationService imageAssociationService; public OcrPersistenceService( OcrServiceClient ocrServiceClient, @@ -72,7 +73,8 @@ public OcrPersistenceService( SchoolYearRepository schoolYearRepository, CourseRepository courseRepository, SubjectRepository subjectRepository, - ClassRepository classRepository + ClassRepository classRepository, + OcrImageAssociationService imageAssociationService ) { this.ocrServiceClient = ocrServiceClient; this.statementRepository = statementRepository; @@ -82,6 +84,7 @@ public OcrPersistenceService( this.courseRepository = courseRepository; this.subjectRepository = subjectRepository; this.classRepository = classRepository; + this.imageAssociationService = imageAssociationService; } // ========================================================================= @@ -97,7 +100,7 @@ public OcrPersistenceService( */ @Transactional public Mono processAndPersist( - List files, + List files, Long createdBy ) { log.info( @@ -107,7 +110,7 @@ public Mono processAndPersist( ); return ocrServiceClient - .extractText(files) + .extractTextFromUploadedFiles(files) .flatMap(ocrResponse -> { if (ocrResponse.isError()) { log.error("OCR extraction failed: requestId={}", @@ -126,7 +129,7 @@ public Mono processAndPersist( : 0 ); - return persistOcrResponse(ocrResponse, createdBy); + return persistOcrResponse(ocrResponse, createdBy, files); }) .doOnSuccess(result -> log.info( @@ -149,7 +152,8 @@ public Mono processAndPersist( @Transactional public Mono persistOcrResponse( OcrResponse ocrResponse, - Long createdBy + Long createdBy, + List sourceFiles ) { OcrMetadata metadata = ocrResponse.metadata(); @@ -198,7 +202,13 @@ public Mono persistOcrResponse( return optionRepository .findAllByQuestionIds(questionIds) .collectList() - .map(options -> + .flatMap(options -> imageAssociationService + .persistQuestionImages( + questions, + ocrResponse.imagesToUpload(), + sourceFiles + ) + .map(ignoredImages -> new StatementWithRelations( statement, schoolYear, @@ -208,8 +218,7 @@ public Mono persistOcrResponse( questions, options, ocrResponse.imagesToUpload() - ) - ); + ))); }); }); } diff --git a/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrImageAssociationServiceTest.java b/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrImageAssociationServiceTest.java new file mode 100644 index 0000000..d56a109 --- /dev/null +++ b/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrImageAssociationServiceTest.java @@ -0,0 +1,99 @@ +package ao.creativemode.kixi.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import javax.imageio.ImageIO; + +import ao.creativemode.kixi.client.OcrUploadedFile; +import ao.creativemode.kixi.dto.ocr.OcrResponse; +import ao.creativemode.kixi.model.Question; +import ao.creativemode.kixi.model.QuestionImage; +import ao.creativemode.kixi.repository.QuestionImageRepository; +import ao.creativemode.kixi.service.storage.ImageStorage; +import ao.creativemode.kixi.service.storage.StoredObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class OcrImageAssociationServiceTest { + + private QuestionImageRepository repository; + private ImageStorage storage; + private OcrImageAssociationService service; + + @BeforeEach + void setUp() { + repository = mock(QuestionImageRepository.class); + storage = mock(ImageStorage.class); + service = new OcrImageAssociationService(repository, storage); + } + + @Test + void cropsRasterQuestionRegionAndAssociatesItWithPersistedQuestion() { + Question question = new Question(); + question.setId(7L); + question.setNumber(3); + + OcrResponse.ImageToUpload region = new OcrResponse.ImageToUpload( + "questao-3.png", + "Figura da questão 3", + "questao_3", + 0, + List.of(2, 2, 8, 8), + 10, + 10, + 1, + 0 + ); + OcrUploadedFile source = new OcrUploadedFile( + "exam.png", + MediaType.IMAGE_PNG, + pngBytes(10, 10) + ); + StoredObject stored = new StoredObject( + "questions/7/ocr-image.png", + "/uploads/questions/7/ocr-image.png" + ); + AtomicReference persisted = new AtomicReference<>(); + when(storage.put(anyString(), any(), any())).thenReturn(Mono.just(stored)); + when(repository.save(any(QuestionImage.class))).thenAnswer(invocation -> { + QuestionImage image = invocation.getArgument(0); + persisted.set(image); + return Mono.just(image); + }); + + StepVerifier.create(service.persistQuestionImages( + List.of(question), List.of(region), List.of(source))) + .assertNext(images -> assertThat(images).hasSize(1)) + .verifyComplete(); + + assertThat(persisted.get().getQuestionId()).isEqualTo(7L); + assertThat(persisted.get().getStorageKey()).isEqualTo(stored.key()); + assertThat(persisted.get().getCaption()).isEqualTo("Figura da questão 3"); + verify(storage).put(anyString(), any(), any(byte[].class)); + } + + private byte[] pngBytes(int width, int height) { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB), "png", output); + return output.toByteArray(); + } catch (IOException error) { + throw new AssertionError(error); + } + } +} diff --git a/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrPersistenceServiceTest.java b/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrPersistenceServiceTest.java index 13a5d39..8719fde 100644 --- a/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrPersistenceServiceTest.java +++ b/services/backend-api/src/test/java/ao/creativemode/kixi/service/OcrPersistenceServiceTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import ao.creativemode.kixi.client.OcrServiceClient; +import ao.creativemode.kixi.client.OcrUploadedFile; import ao.creativemode.kixi.common.exception.ApiException; import ao.creativemode.kixi.dto.ocr.OcrResponse; import ao.creativemode.kixi.repository.ClassRepository; @@ -35,6 +36,7 @@ class OcrPersistenceServiceTest { private CourseRepository courseRepository; private SubjectRepository subjectRepository; private ClassRepository classRepository; + private OcrImageAssociationService imageAssociationService; private OcrPersistenceService service; @BeforeEach @@ -47,6 +49,7 @@ void setUp() { courseRepository = mock(CourseRepository.class); subjectRepository = mock(SubjectRepository.class); classRepository = mock(ClassRepository.class); + imageAssociationService = mock(OcrImageAssociationService.class); service = new OcrPersistenceService( ocrServiceClient, @@ -56,7 +59,8 @@ void setUp() { schoolYearRepository, courseRepository, subjectRepository, - classRepository + classRepository, + imageAssociationService ); } @@ -75,9 +79,15 @@ void convertsOcrErrorIntoBadRequestAndDoesNotTouchRepositories() { List.of(), "Unreadable document" ); - when(ocrServiceClient.extractText(anyList())).thenReturn(Mono.just(response)); + when(ocrServiceClient.extractTextFromUploadedFiles(anyList())).thenReturn(Mono.just(response)); - StepVerifier.create(service.processAndPersist(List.of(), 7L)) + OcrUploadedFile source = new OcrUploadedFile( + "exam.png", + org.springframework.http.MediaType.IMAGE_PNG, + new byte[] {1} + ); + + StepVerifier.create(service.processAndPersist(List.of(source), 7L)) .expectErrorSatisfies(error -> { assertThat(error).isInstanceOf(ApiException.class); ApiException apiException = (ApiException) error; @@ -89,6 +99,6 @@ void convertsOcrErrorIntoBadRequestAndDoesNotTouchRepositories() { verify(statementRepository, never()).save(org.mockito.ArgumentMatchers.any()); verify(questionRepository, never()).save(org.mockito.ArgumentMatchers.any()); - verify(ocrServiceClient).extractText(anyList()); + verify(ocrServiceClient).extractTextFromUploadedFiles(anyList()); } } diff --git a/services/ocr-service/app/api/routes.py b/services/ocr-service/app/api/routes.py index 7f36de9..9d7ae2c 100644 --- a/services/ocr-service/app/api/routes.py +++ b/services/ocr-service/app/api/routes.py @@ -214,8 +214,9 @@ async def extract_text( # Process images all_images = [] + source_file_indices = [] - for upload_file in images: + for source_file_index, upload_file in enumerate(images): try: # Validate file type file_type = validate_file_type(upload_file.filename) @@ -238,6 +239,7 @@ async def extract_text( if file_type == 'pdf' or is_pdf(content): pdf_images = extract_images_from_pdf(content) all_images.extend(pdf_images) + source_file_indices.extend([source_file_index] * len(pdf_images)) logger.debug( "Extracted images from PDF", request_id=request_id, @@ -247,6 +249,7 @@ async def extract_text( # Load regular image image = load_image_from_bytes(content) all_images.append(image) + source_file_indices.append(source_file_index) except HTTPException: raise @@ -274,11 +277,13 @@ async def extract_text( all_images[0], page_index=0, request_id=request_id, + source_file_index=source_file_indices[0], ) else: result = await engine.process_images_async( all_images, request_id=request_id, + source_file_indices=source_file_indices, ) processing_time = int((time.time() - start_time) * 1000) diff --git a/services/ocr-service/app/ocr/engine.py b/services/ocr-service/app/ocr/engine.py index 9c4a66f..cf05ab1 100644 --- a/services/ocr-service/app/ocr/engine.py +++ b/services/ocr-service/app/ocr/engine.py @@ -96,6 +96,11 @@ def to_dict(self) -> Dict[str, Any]: "description": img.description, "region": img.region, "pageIndex": img.page_index, + "bbox": list(img.bbox) if img.bbox else None, + "sourceWidth": img.source_width, + "sourceHeight": img.source_height, + "contractVersion": img.contract_version, + "sourceFileIndex": img.source_file_index, } for img in self.images_to_upload ], @@ -347,6 +352,7 @@ def process_image( image: np.ndarray, page_index: int = 0, request_id: Optional[str] = None, + source_file_index: int = 0, ) -> OCRResult: """ Process a single image and extract text with structure. @@ -404,6 +410,8 @@ def process_image( metadata, questions, images_to_upload, unmapped, warnings = self.postprocessor.process( text_blocks, page_count=1, + page_dimensions={page_index: (int(image.shape[1]), int(image.shape[0]))}, + source_file_indices={page_index: source_file_index}, ) # Calculate confidence @@ -477,6 +485,7 @@ async def process_image_async( image: np.ndarray, page_index: int = 0, request_id: Optional[str] = None, + source_file_index: int = 0, ) -> OCRResult: """ Asynchronously process a single image. @@ -496,12 +505,14 @@ async def process_image_async( image, page_index, request_id, + source_file_index, ) def process_images( self, images: List[np.ndarray], request_id: Optional[str] = None, + source_file_indices: Optional[List[int]] = None, ) -> OCRResult: """ Process multiple images (multi-page document). @@ -546,7 +557,14 @@ def process_images( languages = [] for idx, image in enumerate(images): - result = self.process_image(image, page_index=idx, request_id=request_id) + result = self.process_image( + image, + page_index=idx, + request_id=request_id, + source_file_index=(source_file_indices[idx] + if source_file_indices and idx < len(source_file_indices) + else 0), + ) # Merge results all_questions.extend(result.questions) @@ -602,6 +620,7 @@ async def process_images_async( self, images: List[np.ndarray], request_id: Optional[str] = None, + source_file_indices: Optional[List[int]] = None, ) -> OCRResult: """ Asynchronously process multiple images. @@ -619,6 +638,7 @@ async def process_images_async( self.process_images, images, request_id, + source_file_indices, ) def process_bytes( diff --git a/services/ocr-service/app/ocr/postprocessing.py b/services/ocr-service/app/ocr/postprocessing.py index f403129..e367572 100644 --- a/services/ocr-service/app/ocr/postprocessing.py +++ b/services/ocr-service/app/ocr/postprocessing.py @@ -52,6 +52,10 @@ class ImageToUpload: region: str # questao_1, cabecalho, rodape, etc. bbox: Optional[Tuple[int, int, int, int]] = None page_index: int = 0 + source_width: Optional[int] = None + source_height: Optional[int] = None + contract_version: int = 1 + source_file_index: int = 0 @dataclass @@ -449,6 +453,8 @@ def process( self, text_blocks: List[TextBlock], page_count: int = 1, + page_dimensions: Optional[Dict[int, Tuple[int, int]]] = None, + source_file_indices: Optional[Dict[int, int]] = None, ) -> Tuple[ExtractedMetadata, List[ExtractedQuestion], List[ImageToUpload], List[UnmappedContent], List[Warning]]: """ Process OCR text blocks into structured data. @@ -496,7 +502,14 @@ def process( metadata.total_max_score = MetadataField(round(total_from_map, 1), 0.85) # Detect images to upload - images_to_upload = self._detect_images_to_upload(metadata, questions, sorted_blocks, full_text) + images_to_upload = self._detect_images_to_upload( + metadata, + questions, + sorted_blocks, + full_text, + page_dimensions or {}, + source_file_indices or {}, + ) # Collect unmapped content unmapped = self._collect_unmapped(sorted_blocks, metadata, questions) @@ -1407,11 +1420,41 @@ def _detect_images_to_upload( metadata: ExtractedMetadata, questions: List[ExtractedQuestion], blocks: List[TextBlock], - full_text: str + full_text: str, + page_dimensions: Dict[int, Tuple[int, int]], + source_file_indices: Dict[int, int], ) -> List[ImageToUpload]: """Detect regions that should be uploaded as images.""" images = [] + def dimensions(page_index: int) -> Tuple[Optional[int], Optional[int]]: + return page_dimensions.get(page_index, (None, None)) + + def source_file_index(page_index: int) -> int: + return source_file_indices.get(page_index, 0) + + def region_bbox( + page_index: int, + y_start: int, + y_end: int, + full_width: bool = True, + ) -> Optional[Tuple[int, int, int, int]]: + page_blocks = [b for b in blocks if b.page_index == page_index] + selected = [ + b for b in page_blocks + if b.bbox[3] >= y_start and b.bbox[1] <= y_end + ] + if not selected: + return None + + width, height = dimensions(page_index) + padding = 24 + x1 = 0 if full_width and width else min(b.bbox[0] for b in selected) - padding + x2 = width if full_width and width else max(b.bbox[2] for b in selected) + padding + top = max(0, y_start - padding) + bottom = min(height, y_end + padding) if height else y_end + padding + return (max(0, x1), top, max(x1 + 1, x2), max(top + 1, bottom)) + # Build base filename base_name = "prova" if metadata.subject_name.value: @@ -1425,31 +1468,58 @@ def _detect_images_to_upload( if blocks and len(blocks) > 5: header_text = " ".join(b.text for b in blocks[:5]) if any(kw in header_text.lower() for kw in ["república", "angola", "ministério", "governo", "gabinete"]): + page_index = blocks[0].page_index + bbox = region_bbox(page_index, 0, max(b.bbox[3] for b in blocks[:5])) + source_width, source_height = dimensions(page_index) images.append(ImageToUpload( suggested_filename=f"{base_name}-cabecalho.png", description="Cabeçalho oficial com brasão/logo institucional", region="cabecalho", - page_index=0, + bbox=bbox, + page_index=page_index, + source_width=source_width, + source_height=source_height, + source_file_index=source_file_index(page_index), )) # Add images for questions with visual content for question in questions: if question.has_image: + source_width, source_height = dimensions(question.page_index) + bbox = region_bbox( + question.page_index, + question.start_y, + max(question.end_y, question.start_y + 1), + ) images.append(ImageToUpload( suggested_filename=f"{base_name}-questao-{question.number}.png", description=question.image_description or f"Imagem da questão {question.number}", region=f"questao_{question.number}", + bbox=bbox, page_index=question.page_index, + source_width=source_width, + source_height=source_height, + source_file_index=source_file_index(question.page_index), )) # Check for coordination/signature at footer for pattern in self.PATTERNS["coordination"]: if re.search(pattern, full_text.lower()): + page_index = max((b.page_index for b in blocks), default=0) + footer_blocks = [b for b in blocks if b.page_index == page_index][-5:] + footer_start = min((b.bbox[1] for b in footer_blocks), default=0) + footer_end = max((b.bbox[3] for b in footer_blocks), default=footer_start) + bbox = region_bbox(page_index, footer_start, footer_end) + source_width, source_height = dimensions(page_index) images.append(ImageToUpload( suggested_filename=f"{base_name}-assinatura-coordenacao.png", description="Assinatura da coordenação no rodapé da prova com texto A COORDENAÇÃO", region="rodape", - page_index=len(set(b.page_index for b in blocks)) - 1 if blocks else 0, + bbox=bbox, + page_index=page_index, + source_width=source_width, + source_height=source_height, + source_file_index=source_file_index(page_index), )) break diff --git a/services/ocr-service/tests/test_postprocessing_edges.py b/services/ocr-service/tests/test_postprocessing_edges.py index 8d03a95..62210aa 100644 --- a/services/ocr-service/tests/test_postprocessing_edges.py +++ b/services/ocr-service/tests/test_postprocessing_edges.py @@ -144,6 +144,34 @@ def test_process_detects_header_question_and_footer_image_regions(): assert isinstance(warnings, list) +def test_process_emits_versioned_raster_region_contract(): + processor = OCRPostprocessor() + blocks = [ + block("República de Angola", 10), + block("Ministério da Educação", 30), + block("PROVA DE EXAME DE MATEMÁTICA", 50), + block("Ano Letivo: 2024/2025", 70), + block("12ª Classe Série B", 90), + block("1. Observe a figura que mostra uma reta", 120), + block("A) cinco", 150), + block("B) dez", 180), + block("A COORDENAÇÃO", 220), + ] + + _, _, images, _, _ = processor.process( + blocks, + page_dimensions={0: (1000, 800)}, + ) + + question_image = next(image for image in images if image.region == "questao_1") + assert question_image.contract_version == 1 + assert question_image.bbox == (0, 96, 1000, 224) + assert question_image.page_index == 0 + assert question_image.source_width == 1000 + assert question_image.source_height == 800 + assert question_image.source_file_index == 0 + + def test_process_keeps_lowercase_multipart_items_as_subitems(): processor = OCRPostprocessor() blocks = [