Skip to content

Commit d625220

Browse files
authored
Complete OCR question image persistence (#94)
* Add S3-compatible question image storage * Complete OCR region persistence contract
1 parent d354e12 commit d625220

27 files changed

Lines changed: 1339 additions & 104 deletions

‎docker-compose.yml‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ services:
8888
APP_JWT_EXPIRATION_MS: ${APP_JWT_EXPIRATION_MS:-86400000}
8989
APP_AUTH_GOOGLE_STATE_COOKIE_SECURE: ${GOOGLE_OAUTH_STATE_COOKIE_SECURE:-true}
9090
OCR_SERVICE_API_KEY: ${OCR_API_KEY:?Set OCR_API_KEY for backend-to-OCR authentication}
91+
# Object storage. Keep local for the default development profile;
92+
# use STORAGE_PROVIDER=s3 with --profile storage for MinIO.
93+
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
94+
STORAGE_LOCAL_ROOT: ${STORAGE_LOCAL_ROOT:-/tmp/kixi/uploads}
95+
STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-/uploads}
96+
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://minio:9000}
97+
STORAGE_S3_REGION: ${STORAGE_S3_REGION:-us-east-1}
98+
STORAGE_S3_BUCKET: ${STORAGE_S3_BUCKET:-kixi-images}
99+
STORAGE_S3_ACCESS_KEY: ${STORAGE_S3_ACCESS_KEY:-kixi-minio}
100+
STORAGE_S3_SECRET_KEY: ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret}
101+
STORAGE_S3_PATH_STYLE_ACCESS: ${STORAGE_S3_PATH_STYLE_ACCESS:-true}
102+
STORAGE_MAX_OBJECT_SIZE_BYTES: ${STORAGE_MAX_OBJECT_SIZE_BYTES:-20971520}
91103
ports:
92104
- "${BACKEND_PORT:-8080}:8080"
93105
depends_on:
@@ -255,6 +267,53 @@ services:
255267
networks:
256268
- kixi-network
257269

270+
# ===========================================================================
271+
# S3-compatible object storage (Optional Profile)
272+
# ===========================================================================
273+
# Start with STORAGE_PROVIDER=s3 docker-compose --profile storage up
274+
minio:
275+
image: minio/minio:RELEASE.2024-06-13T22-53-53Z
276+
container_name: kixi-minio
277+
profiles:
278+
- storage
279+
restart: unless-stopped
280+
command: server /data --console-address ":9001"
281+
environment:
282+
MINIO_ROOT_USER: ${STORAGE_S3_ACCESS_KEY:-kixi-minio}
283+
MINIO_ROOT_PASSWORD: ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret}
284+
ports:
285+
- "${MINIO_PORT:-9000}:9000"
286+
- "${MINIO_CONSOLE_PORT:-9001}:9001"
287+
volumes:
288+
- minio_data:/data
289+
healthcheck:
290+
test: ["CMD", "mc", "ready", "local"]
291+
interval: 10s
292+
timeout: 5s
293+
retries: 5
294+
networks:
295+
- kixi-network
296+
297+
minio-init:
298+
image: minio/mc:RELEASE.2024-06-13T21-21-32Z
299+
container_name: kixi-minio-init
300+
profiles:
301+
- storage
302+
depends_on:
303+
minio:
304+
condition: service_healthy
305+
entrypoint: /bin/sh
306+
command:
307+
- -c
308+
- >-
309+
mc alias set local http://minio:9000
310+
${STORAGE_S3_ACCESS_KEY:-kixi-minio}
311+
${STORAGE_S3_SECRET_KEY:-kixi-minio-secret}
312+
&& mc mb --ignore-existing local/${STORAGE_S3_BUCKET:-kixi-images}
313+
&& mc anonymous set download local/${STORAGE_S3_BUCKET:-kixi-images}
314+
networks:
315+
- kixi-network
316+
258317
# =============================================================================
259318
# Networks
260319
# =============================================================================
@@ -282,3 +341,7 @@ volumes:
282341
# Redis data persistence
283342
redis_data:
284343
name: kixi-redis-data
344+
345+
# MinIO object data for local integration runs
346+
minio_data:
347+
name: kixi-minio-data

‎docs/IMAGE_STORAGE.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Image storage
2+
3+
Question images are stored through the `ImageStorage` port. The backend supports:
4+
5+
- `local` (default): writes below `storage.local-root` and preserves the existing
6+
`/uploads` URL base for local development;
7+
- `s3`: uses AWS S3 or any S3-compatible endpoint such as MinIO.
8+
9+
The object key is generated by the backend (`questions/{questionId}/{uuid}.ext`).
10+
The multipart filename is never used as a path component. Only JPEG, PNG and
11+
WebP are accepted, and the default maximum object size is 20 MiB. The key is
12+
stored in `question_images.storage_key`; legacy rows remain purgeable without a
13+
storage delete because they predate this column.
14+
15+
## MinIO local integration
16+
17+
Run the optional profile with the S3 provider enabled:
18+
19+
```bash
20+
STORAGE_PROVIDER=s3 \
21+
STORAGE_PUBLIC_BASE_URL=http://localhost:9000/kixi-images \
22+
docker compose --profile storage up --build
23+
```
24+
25+
The profile creates the `kixi-images` bucket and enables anonymous downloads for
26+
local development only. Do not carry that bucket policy to production. In
27+
production, configure a private bucket with a controlled CDN or signed delivery
28+
endpoint through `STORAGE_PUBLIC_BASE_URL`.
29+
30+
## OCR boundary
31+
32+
OCR returns a versioned region contract (`contractVersion: 1`) with page index,
33+
source-file index, source dimensions and a bounded crop rectangle. The
34+
persistence endpoint retains the submitted raster bytes, crops `questao_N`
35+
regions, stores the PNG through the same `ImageStorage` port and associates it
36+
with the persisted question.
37+
38+
PDF sources and `cabecalho`/`rodape` regions remain metadata-only in this slice:
39+
the OCR service renders PDF pages internally and `question_images` requires a
40+
question association. A future statement-asset contract must define those
41+
cases before they are persisted automatically.

‎services/backend-api/pom.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727
</properties>
2828

2929
<dependencies>
30+
<dependency>
31+
<groupId>software.amazon.awssdk</groupId>
32+
<artifactId>s3</artifactId>
33+
<version>2.25.60</version>
34+
</dependency>
35+
3036
<dependency>
3137
<groupId>org.springframework.boot</groupId>
3238
<artifactId>spring-boot-starter-webflux</artifactId>

‎services/backend-api/src/main/java/ao/creativemode/kixi/client/OcrServiceClient.java‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,53 @@ public Mono<OcrResponse> extractText(List<FilePart> files) {
139139
.doOnError(error -> log.error("OCR request failed: type={}", error.getClass().getSimpleName()));
140140
}
141141

142+
/**
143+
* Extract text from bytes retained by the persistence flow. This variant
144+
* avoids replaying a consumed multipart stream when OCR regions are later
145+
* associated with their source page.
146+
*/
147+
public Mono<OcrResponse> extractTextFromUploadedFiles(List<OcrUploadedFile> files) {
148+
if (files == null || files.isEmpty()) {
149+
return Mono.error(new IllegalArgumentException("At least one file is required"));
150+
}
151+
152+
MultipartBodyBuilder builder = new MultipartBodyBuilder();
153+
for (OcrUploadedFile file : files) {
154+
builder.part("images", file.content())
155+
.filename(file.filename())
156+
.contentType(file.contentType() != null
157+
? file.contentType()
158+
: getContentType(file.filename()));
159+
}
160+
161+
log.info("Sending retained OCR request: {} file(s)", files.size());
162+
return webClient.post()
163+
.uri("/ocr/v1/extract")
164+
.headers(this::applyAuthentication)
165+
.contentType(MediaType.MULTIPART_FORM_DATA)
166+
.body(BodyInserters.fromMultipartData(builder.build()))
167+
.retrieve()
168+
.onStatus(HttpStatusCode::is4xxClientError, response ->
169+
response.bodyToMono(String.class)
170+
.flatMap(body -> Mono.error(new OcrClientException(
171+
"OCR request failed: " + body,
172+
response.statusCode().value()))))
173+
.onStatus(HttpStatusCode::is5xxServerError, response ->
174+
response.bodyToMono(String.class)
175+
.flatMap(body -> Mono.error(new OcrServerException(
176+
"OCR service error: " + body,
177+
response.statusCode().value()))))
178+
.bodyToMono(OcrResponse.class)
179+
.timeout(timeout)
180+
.retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1))
181+
.filter(this::isRetryable))
182+
.doOnSuccess(response -> log.info(
183+
"OCR retained request successful: requestId={}, status={}",
184+
response.requestId(), response.status()))
185+
.doOnError(error -> log.error("OCR retained request failed: type={}",
186+
error.getClass().getSimpleName()));
187+
}
188+
142189
/**
143190
* Extract text from raw image bytes.
144191
*
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package ao.creativemode.kixi.client;
2+
3+
import org.springframework.http.MediaType;
4+
5+
/**
6+
* Immutable upload retained for OCR persistence. Keeping the bytes here lets
7+
* the backend associate OCR regions with the exact source page it submitted.
8+
*/
9+
public record OcrUploadedFile(
10+
String filename,
11+
MediaType contentType,
12+
byte[] content) {
13+
14+
public OcrUploadedFile {
15+
if (filename == null || filename.isBlank()) {
16+
throw new IllegalArgumentException("Filename is required");
17+
}
18+
if (content == null || content.length == 0) {
19+
throw new IllegalArgumentException("File content cannot be empty");
20+
}
21+
}
22+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package ao.creativemode.kixi.config;
2+
3+
import java.net.URI;
4+
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
10+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
11+
import software.amazon.awssdk.regions.Region;
12+
import software.amazon.awssdk.services.s3.S3Client;
13+
import software.amazon.awssdk.services.s3.S3Configuration;
14+
15+
@Configuration(proxyBeanMethods = false)
16+
public class StorageConfiguration {
17+
18+
@Bean
19+
@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "s3")
20+
S3Client s3Client(StorageProperties properties) {
21+
if (properties.getS3AccessKey().isBlank() || properties.getS3SecretKey().isBlank()) {
22+
throw new IllegalStateException("storage.s3-access-key and storage.s3-secret-key must be configured for S3 storage");
23+
}
24+
25+
var builder = S3Client.builder()
26+
.region(Region.of(properties.getS3Region()))
27+
.credentialsProvider(StaticCredentialsProvider.create(
28+
AwsBasicCredentials.create(properties.getS3AccessKey(), properties.getS3SecretKey())))
29+
.serviceConfiguration(S3Configuration.builder()
30+
.pathStyleAccessEnabled(properties.isS3PathStyleAccess())
31+
.build());
32+
33+
if (!properties.getS3Endpoint().isBlank()) {
34+
builder.endpointOverride(URI.create(properties.getS3Endpoint()));
35+
}
36+
return builder.build();
37+
}
38+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package ao.creativemode.kixi.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
/**
7+
* Object storage configuration. The default keeps local development compatible
8+
* with the existing static upload directory; production should use S3.
9+
*/
10+
@Component
11+
@ConfigurationProperties(prefix = "storage")
12+
public class StorageProperties {
13+
14+
private String provider = "local";
15+
private String localRoot = "services/backend-api/src/main/resources/static/uploads";
16+
private String publicBaseUrl = "/uploads";
17+
private String s3Endpoint = "";
18+
private String s3Region = "us-east-1";
19+
private String s3Bucket = "kixi-images";
20+
private String s3AccessKey = "";
21+
private String s3SecretKey = "";
22+
private boolean s3PathStyleAccess = true;
23+
private long maxObjectSizeBytes = 20L * 1024 * 1024;
24+
25+
public String getProvider() {
26+
return provider;
27+
}
28+
29+
public void setProvider(String provider) {
30+
this.provider = provider;
31+
}
32+
33+
public String getLocalRoot() {
34+
return localRoot;
35+
}
36+
37+
public void setLocalRoot(String localRoot) {
38+
this.localRoot = localRoot;
39+
}
40+
41+
public String getPublicBaseUrl() {
42+
return publicBaseUrl;
43+
}
44+
45+
public void setPublicBaseUrl(String publicBaseUrl) {
46+
this.publicBaseUrl = publicBaseUrl;
47+
}
48+
49+
public String getS3Endpoint() {
50+
return s3Endpoint;
51+
}
52+
53+
public void setS3Endpoint(String s3Endpoint) {
54+
this.s3Endpoint = s3Endpoint;
55+
}
56+
57+
public String getS3Region() {
58+
return s3Region;
59+
}
60+
61+
public void setS3Region(String s3Region) {
62+
this.s3Region = s3Region;
63+
}
64+
65+
public String getS3Bucket() {
66+
return s3Bucket;
67+
}
68+
69+
public void setS3Bucket(String s3Bucket) {
70+
this.s3Bucket = s3Bucket;
71+
}
72+
73+
public String getS3AccessKey() {
74+
return s3AccessKey;
75+
}
76+
77+
public void setS3AccessKey(String s3AccessKey) {
78+
this.s3AccessKey = s3AccessKey;
79+
}
80+
81+
public String getS3SecretKey() {
82+
return s3SecretKey;
83+
}
84+
85+
public void setS3SecretKey(String s3SecretKey) {
86+
this.s3SecretKey = s3SecretKey;
87+
}
88+
89+
public boolean isS3PathStyleAccess() {
90+
return s3PathStyleAccess;
91+
}
92+
93+
public void setS3PathStyleAccess(boolean s3PathStyleAccess) {
94+
this.s3PathStyleAccess = s3PathStyleAccess;
95+
}
96+
97+
public long getMaxObjectSizeBytes() {
98+
return maxObjectSizeBytes;
99+
}
100+
101+
public void setMaxObjectSizeBytes(long maxObjectSizeBytes) {
102+
this.maxObjectSizeBytes = maxObjectSizeBytes;
103+
}
104+
}

0 commit comments

Comments
 (0)