Add Builder and configurable text extraction to DoclingDocumentParser (#5818)

## Summary
- Introduce a fluent `Builder` for `DoclingDocumentParser` with an
optional `Function<InBodyConvertDocumentResponse, String>`
(`documentTextExtractor`) to customize how document text is extracted
from the Docling conversion response
- Default behavior (markdown extraction) is preserved — the extractor
defaults to `response.getDocument().getMarkdownContent()`
- Deprecate the existing public constructors in favor of the builder,
delegating internally to the builder-based constructor
- Bump `docling-serve-api`/`docling-serve-client` version from 0.5.2 to
0.6.0
- Update documentation at
`docs/docs/integrations/document-parsers/docling.md` with builder usage
and custom extraction examples

## Test plan
- [x] All 17 unit tests pass (7 existing + 10 new) covering builder
construction, custom extractors (HTML, text, doctags), null/blank
extractor results, full response metadata access, and builder with
options
- [x] Integration test with custom extractor against real Docling
container (`DoclingDocumentParserIT`)
- [x] Verify backward compatibility — existing constructor-based tests
pass without modification

---------

Signed-off-by: Eric Deandrea <eric.deandrea@ibm.com>
This commit is contained in:
Eric Deandrea 2026-07-30 04:47:15 -04:00 committed by GitHub
parent 4b5b6675c1
commit aea344c244
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 270 additions and 30 deletions

View File

@ -43,19 +43,49 @@ DoclingServeApi api = DoclingServeApi.builder()
.baseUrl("http://localhost:5001")
.build();
DoclingDocumentParser parser = new DoclingDocumentParser(api);
DoclingDocumentParser parser = DoclingDocumentParser.builder()
.doclingClient(api)
.build();
Document document = parser.parse(inputStream);
String text = document.text();
```
To customize Docling processing, use the constructor that also accepts a [`ConvertDocumentOptions`](https://docling-project.github.io/docling-java/dev/docling-serve/serve-api/#requests-convertdocumentrequest):
### Conversion Options
To customize Docling processing, pass [`ConvertDocumentOptions`](https://docling-project.github.io/docling-java/dev/docling-serve/serve-api/#requests-convertdocumentrequest) to the builder:
```java
ConvertDocumentOptions options = ConvertDocumentOptions.builder()
// configure options here
.build();
DoclingDocumentParser parser = new DoclingDocumentParser(api, options);
DoclingDocumentParser parser = DoclingDocumentParser.builder()
.doclingClient(api)
.options(options)
.build();
```
### Custom Text Extraction
By default, the parser extracts markdown content from the Docling response. You can customize how text is extracted by providing a `Function<InBodyConvertDocumentResponse, String>` via the `documentTextExtractor` builder method. The function receives the full `InBodyConvertDocumentResponse`, giving access to the converted document in various formats (markdown, HTML, text, doctags, JSON), conversion errors, processing time, and status information.
For example, to extract HTML content instead of markdown:
```java
DoclingDocumentParser parser = DoclingDocumentParser.builder()
.doclingClient(api)
.documentTextExtractor(response -> response.getDocument().getHtmlContent())
.build();
```
Or to extract plain text:
```java
DoclingDocumentParser parser = DoclingDocumentParser.builder()
.doclingClient(api)
.documentTextExtractor(response -> response.getDocument().getTextContent())
.build();
```
## APIs

View File

@ -13,7 +13,7 @@
<name>LangChain4j :: Document Parser :: Docling</name>
<properties>
<docling.version>0.5.2</docling.version>
<docling.version>0.6.0</docling.version>
</properties>
<dependencyManagement>

View File

@ -14,6 +14,11 @@
"new": "missing-class ai.docling.serve.api.convert.request.options.ConvertDocumentOptions",
"justification": "Docling Serve API type intentionally exposed by the Docling document parser integration for document conversion options"
},
{
"code": "java.class.externalClassExposedInAPI",
"new": "missing-class ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse",
"justification": "Docling Serve API type intentionally exposed to allow custom document text extraction"
},
{
"code": "java.class.externalClassExposedInAPI",
"new": "missing-class dev.langchain4j.data.document.Document",

View File

@ -1,11 +1,12 @@
package dev.langchain4j.data.document.parser.docling;
import static dev.langchain4j.internal.Utils.getOrDefault;
import ai.docling.serve.api.DoclingServeApi;
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions;
import ai.docling.serve.api.convert.request.source.FileSource;
import ai.docling.serve.api.convert.response.ConvertDocumentResponse;
import ai.docling.serve.api.convert.response.ErrorItem;
import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse;
import ai.docling.serve.api.convert.response.ResponseType;
import dev.langchain4j.data.document.BlankDocumentException;
@ -16,22 +17,38 @@ import dev.langchain4j.internal.ValidationUtils;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DoclingDocumentParser implements DocumentParser {
private static final Logger log = LoggerFactory.getLogger(DoclingDocumentParser.class);
private static final Function<InBodyConvertDocumentResponse, String> DEFAULT_DOCUMENT_TEXT_EXTRACTOR =
response -> response.getDocument().getMarkdownContent();
private final DoclingServeApi doclingClient;
private final ConvertDocumentOptions options;
private final Function<InBodyConvertDocumentResponse, String> documentTextExtractor;
@Deprecated(forRemoval = true)
public DoclingDocumentParser(DoclingServeApi doclingClient) {
this(doclingClient, null);
}
@Deprecated(forRemoval = true)
public DoclingDocumentParser(DoclingServeApi doclingClient, ConvertDocumentOptions options) {
this.doclingClient = ValidationUtils.ensureNotNull(doclingClient, "doclingClient");
this.options = options;
this(builder().doclingClient(doclingClient).options(options));
}
private DoclingDocumentParser(Builder builder) {
this.doclingClient = ValidationUtils.ensureNotNull(builder.doclingClient, "doclingClient");
this.options = builder.options;
this.documentTextExtractor = getOrDefault(builder.documentTextExtractor, DEFAULT_DOCUMENT_TEXT_EXTRACTOR);
}
public static Builder builder() {
return new Builder();
}
@Override
@ -41,16 +58,16 @@ public class DoclingDocumentParser implements DocumentParser {
try {
byte[] documentBytes = inputStream.readAllBytes();
Metadata metadata = new Metadata();
var metadata = new Metadata();
metadata.put("document_size_bytes", String.valueOf(documentBytes.length));
if (documentBytes.length == 0) {
throw new BlankDocumentException();
}
String base64Content = Base64.getEncoder().encodeToString(documentBytes);
var base64Content = Base64.getEncoder().encodeToString(documentBytes);
ConvertDocumentRequest.Builder requestBuilder = ConvertDocumentRequest.builder()
var requestBuilder = ConvertDocumentRequest.builder()
.source(FileSource.builder()
.base64String(base64Content)
.filename("document")
@ -68,10 +85,10 @@ public class DoclingDocumentParser implements DocumentParser {
.formatted(ResponseType.IN_BODY, response.getResponseType()));
}
InBodyConvertDocumentResponse inBodyResponse = (InBodyConvertDocumentResponse) response;
var inBodyResponse = (InBodyConvertDocumentResponse) response;
if (!inBodyResponse.getErrors().isEmpty()) {
ErrorItem first = inBodyResponse.getErrors().get(0);
var first = inBodyResponse.getErrors().get(0);
log.warn(
"Docling reported {} error(s). First: [{}] {}",
inBodyResponse.getErrors().size(),
@ -79,8 +96,8 @@ public class DoclingDocumentParser implements DocumentParser {
first.getErrorMessage());
}
String parsedText = inBodyResponse.getDocument().getMarkdownContent();
if ((parsedText == null) || parsedText.strip().isEmpty()) {
var parsedText = documentTextExtractor.apply(inBodyResponse);
if ((parsedText == null) || parsedText.isBlank()) {
throw new BlankDocumentException();
}
@ -93,4 +110,44 @@ public class DoclingDocumentParser implements DocumentParser {
throw new RuntimeException("Docling failed to parse document: " + e.getMessage(), e);
}
}
public static final class Builder {
private DoclingServeApi doclingClient;
private ConvertDocumentOptions options;
private Function<InBodyConvertDocumentResponse, String> documentTextExtractor;
private Builder() {}
public Builder doclingClient(DoclingServeApi doclingClient) {
this.doclingClient = doclingClient;
return this;
}
public Builder options(ConvertDocumentOptions options) {
this.options = options;
return this;
}
/**
* Sets a custom function to extract text content from the Docling conversion response.
* <p>
* The function receives the full {@link InBodyConvertDocumentResponse}, giving access to
* the converted document (in various formats: markdown, HTML, text, doctags, JSON),
* conversion errors, processing time, and status information.
* <p>
* If not set, defaults to extracting markdown content:
* {@code response -> response.getDocument().getMarkdownContent()}.
*
* @param documentTextExtractor the function to extract document text from the response
* @return this builder
*/
public Builder documentTextExtractor(Function<InBodyConvertDocumentResponse, String> documentTextExtractor) {
this.documentTextExtractor = documentTextExtractor;
return this;
}
public DoclingDocumentParser build() {
return new DoclingDocumentParser(this);
}
}
}

View File

@ -52,4 +52,20 @@ class DoclingDocumentParserIT {
assertThat(document.text()).isNotEmpty();
}
}
@Test
void shouldParsePdfDocumentWithCustomExtractor() throws Exception {
var parser = DoclingDocumentParser.builder()
.doclingClient(client)
.documentTextExtractor(
response -> response.getDocument().getMarkdownContent().toUpperCase())
.build();
try (InputStream inputStream = getClass().getResourceAsStream("/test-file.pdf")) {
Document document = parser.parse(inputStream);
assertThat(document).isNotNull();
assertThat(document.text()).isNotEmpty().isUpperCase();
}
}
}

View File

@ -7,6 +7,7 @@ import static org.mockito.Mockito.when;
import ai.docling.serve.api.DoclingServeApi;
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions;
import ai.docling.serve.api.convert.response.DocumentResponse;
import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse;
import dev.langchain4j.data.document.BlankDocumentException;
@ -48,12 +49,7 @@ class DoclingDocumentParserTest {
@Test
void shouldReturnDocumentWithParsedText() {
when(mockApi.convertSource(any(ConvertDocumentRequest.class)))
.thenReturn(InBodyConvertDocumentResponse.builder()
.document(DocumentResponse.builder()
.markdownContent("# Parsed Content")
.build())
.build());
mockResponseWithMarkdown("# Parsed Content");
DoclingDocumentParser parser = new DoclingDocumentParser(mockApi);
Document document = parser.parse(new ByteArrayInputStream("some bytes".getBytes()));
@ -64,12 +60,7 @@ class DoclingDocumentParserTest {
@Test
void shouldIncludeDocumentSizeBytesInMetadata() {
byte[] content = "document content".getBytes();
when(mockApi.convertSource(any(ConvertDocumentRequest.class)))
.thenReturn(InBodyConvertDocumentResponse.builder()
.document(DocumentResponse.builder()
.markdownContent("Parsed text")
.build())
.build());
mockResponseWithMarkdown("Parsed text");
DoclingDocumentParser parser = new DoclingDocumentParser(mockApi);
Document document = parser.parse(new ByteArrayInputStream(content));
@ -79,10 +70,7 @@ class DoclingDocumentParserTest {
@Test
void shouldHandleEmptyDocumentWhenApiReturnsEmptyContent() {
when(mockApi.convertSource(any(ConvertDocumentRequest.class)))
.thenReturn(InBodyConvertDocumentResponse.builder()
.document(DocumentResponse.builder().markdownContent("").build())
.build());
mockResponseWithMarkdown("");
assertThatThrownBy(() -> new DoclingDocumentParser(mockApi).parse(new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(BlankDocumentException.class);
@ -92,4 +80,148 @@ class DoclingDocumentParserTest {
void shouldImplementDocumentParserInterface() {
assertThat(new DoclingDocumentParser(mockApi)).isInstanceOf(DocumentParser.class);
}
@Test
void shouldReturnDocumentWithParsedText_usingBuilder() {
mockResponseWithMarkdown("# Parsed Content");
var parser = DoclingDocumentParser.builder().doclingClient(mockApi).build();
var document = parser.parse(new ByteArrayInputStream("some bytes".getBytes()));
assertThat(document.text()).isEqualTo("# Parsed Content");
}
@Test
void shouldThrowWhenDoclingClientIsNull_usingBuilder() {
assertThatThrownBy(() -> DoclingDocumentParser.builder().build()).isInstanceOf(IllegalArgumentException.class);
}
@Test
void shouldUseCustomDocumentTextExtractor() {
mockResponseWith("# Markdown", "<h1>HTML</h1>", null, null);
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.documentTextExtractor(response -> response.getDocument().getHtmlContent())
.build();
var document = parser.parse(new ByteArrayInputStream("some bytes".getBytes()));
assertThat(document.text()).isEqualTo("<h1>HTML</h1>");
}
@Test
void shouldUseDefaultMarkdownExtractorWhenNoneSpecified() {
mockResponseWith("# Markdown", "<h1>HTML</h1>", "Plain text", null);
var parser = DoclingDocumentParser.builder().doclingClient(mockApi).build();
var document = parser.parse(new ByteArrayInputStream("some bytes".getBytes()));
assertThat(document.text()).isEqualTo("# Markdown");
}
@Test
void shouldThrowBlankDocumentExceptionWhenExtractorReturnsNull() {
mockResponseWithMarkdown("# Markdown");
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.documentTextExtractor(response -> null)
.build();
assertThatThrownBy(() -> parser.parse(new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(BlankDocumentException.class);
}
@Test
void shouldThrowBlankDocumentExceptionWhenExtractorReturnsBlank() {
mockResponseWithMarkdown("# Markdown");
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.documentTextExtractor(response -> " ")
.build();
assertThatThrownBy(() -> parser.parse(new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(BlankDocumentException.class);
}
@Test
void shouldAllowAccessToFullResponse() {
when(mockApi.convertSource(any(ConvertDocumentRequest.class)))
.thenReturn(InBodyConvertDocumentResponse.builder()
.document(DocumentResponse.builder()
.markdownContent("content")
.build())
.status("SUCCESS")
.processingTime(1.5)
.build());
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.documentTextExtractor(response -> "%s (status=%s, time=%.1f)"
.formatted(
response.getDocument().getMarkdownContent(),
response.getStatus(),
response.getProcessingTime()))
.build();
var document = parser.parse(new ByteArrayInputStream("data".getBytes()));
assertThat(document.text()).isEqualTo("content (status=SUCCESS, time=1.5)");
}
@Test
void shouldBuildWithOptions() {
mockResponseWithMarkdown("# Content");
var options = ConvertDocumentOptions.builder().build();
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.options(options)
.build();
var document = parser.parse(new ByteArrayInputStream("data".getBytes()));
assertThat(document.text()).isEqualTo("# Content");
}
@Test
void shouldExtractTextContent() {
mockResponseWith(null, null, "Plain text content", null);
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.documentTextExtractor(response -> response.getDocument().getTextContent())
.build();
var document = parser.parse(new ByteArrayInputStream("data".getBytes()));
assertThat(document.text()).isEqualTo("Plain text content");
}
@Test
void shouldExtractDoctagsContent() {
mockResponseWith(null, null, null, "<doctag>content</doctag>");
var parser = DoclingDocumentParser.builder()
.doclingClient(mockApi)
.documentTextExtractor(response -> response.getDocument().getDoctagsContent())
.build();
var document = parser.parse(new ByteArrayInputStream("data".getBytes()));
assertThat(document.text()).isEqualTo("<doctag>content</doctag>");
}
private void mockResponseWithMarkdown(String markdown) {
mockResponseWith(markdown, null, null, null);
}
private void mockResponseWith(String markdown, String html, String text, String doctags) {
when(mockApi.convertSource(any(ConvertDocumentRequest.class)))
.thenReturn(InBodyConvertDocumentResponse.builder()
.document(DocumentResponse.builder()
.markdownContent(markdown)
.htmlContent(html)
.textContent(text)
.doctagsContent(doctags)
.build())
.build());
}
}