feat(ocr): implement NDJSON streaming in RestClientOcrClient

Add streamBlocks() that POSTs to /ocr/stream and parses the NDJSON
response line by line with a dedicated ObjectMapper. Falls back to
the old /ocr endpoint via the default method when /ocr/stream returns
404. Uses a separate HttpClient with 5-minute request timeout for
streaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-13 10:03:12 +02:00
parent 641e91d5a3
commit 93c3154b3c
2 changed files with 218 additions and 0 deletions

View File

@@ -1,6 +1,10 @@
package org.raddatz.familienarchiv.service;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.ScriptType;
import org.springframework.beans.factory.annotation.Value;
@@ -10,18 +14,34 @@ import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
@Component
@Slf4j
public class RestClientOcrClient implements OcrClient, OcrHealthClient {
private static final ObjectMapper NDJSON_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
private final RestClient restClient;
private final HttpClient streamingHttpClient;
private final String baseUrl;
public RestClientOcrClient(@Value("${app.ocr.base-url:http://ocr-service:8000}") String baseUrl) {
this.baseUrl = baseUrl;
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
@@ -33,6 +53,11 @@ public class RestClientOcrClient implements OcrClient, OcrHealthClient {
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
this.streamingHttpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
}
@Override
@@ -70,6 +95,82 @@ public class RestClientOcrClient implements OcrClient, OcrHealthClient {
}
}
@Override
public void streamBlocks(String pdfUrl, ScriptType scriptType, Consumer<OcrStreamEvent> handler) {
String body;
try {
body = NDJSON_MAPPER.writeValueAsString(Map.of(
"pdfUrl", pdfUrl,
"scriptType", scriptType.name(),
"language", "de"));
} catch (IOException e) {
throw new RuntimeException("Failed to serialize OCR request", e);
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ocr/stream"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofMinutes(5))
.build();
try {
HttpResponse<InputStream> response = streamingHttpClient.send(
request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() == 404) {
log.info("OCR service does not support /ocr/stream (404), falling back to /ocr");
OcrClient.super.streamBlocks(pdfUrl, scriptType, handler);
return;
}
try (InputStream inputStream = response.body()) {
parseNdjsonStream(inputStream, handler);
}
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new RuntimeException("NDJSON stream failed: " + e.getMessage(), e);
}
}
static void parseNdjsonStream(InputStream inputStream, Consumer<OcrStreamEvent> handler) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;
JsonNode node = NDJSON_MAPPER.readTree(line);
String type = node.path("type").asText();
switch (type) {
case "start" -> handler.accept(
new OcrStreamEvent.Start(node.path("totalPages").asInt()));
case "page" -> {
int pageNumber = node.path("pageNumber").asInt();
List<OcrBlockResult> blocks = NDJSON_MAPPER.convertValue(
node.path("blocks"),
new TypeReference<>() {});
handler.accept(new OcrStreamEvent.Page(pageNumber, blocks));
}
case "error" -> handler.accept(
new OcrStreamEvent.Error(
node.path("pageNumber").asInt(),
node.path("message").asText()));
case "done" -> handler.accept(
new OcrStreamEvent.Done(
node.path("totalBlocks").asInt(),
node.path("skippedPages").asInt()));
default -> log.debug("Ignoring unknown NDJSON event type: {}", type);
}
}
} catch (IOException e) {
throw new RuntimeException("Failed to parse NDJSON stream: " + e.getMessage(), e);
}
}
record OcrBlockJson(
@JsonProperty("pageNumber") int pageNumber,
double x,