Feature/pdf ingestion jpdfium (#6525)

This commit is contained in:
EthanHealy01
2026-06-10 14:51:41 +01:00
committed by GitHub
parent 2aa6768921
commit 5fca2f199a
36 changed files with 2439 additions and 2021 deletions
@@ -21,7 +21,8 @@ public enum AiWorkflowOutcome {
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
CANNOT_CONTINUE("cannot_continue"),
GENERATE_FILE("generate_file");
GENERATE_FILE("generate_file"),
CONVERT_MARKDOWN("convert_markdown");
private final String value;
@@ -66,6 +66,7 @@ import tools.jackson.databind.ObjectMapper;
public class AiWorkflowService {
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final AiEngineClient aiEngineClient;
@@ -194,6 +195,7 @@ public class AiWorkflowService {
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
case TOOL_CALL -> onToolCall(response, filesById, listener);
case PLAN -> onPlan(response, filesById, request, listener);
case ANSWER -> onAnswer(response, filesById, request, listener);
@@ -330,6 +332,77 @@ public class AiWorkflowService {
return new WorkflowState.Pending(nextRequest);
}
/**
* Deterministically convert each requested PDF to Markdown via the {@code
* /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
* {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
* answer.
*/
private WorkflowState onConvertMarkdown(
AiWorkflowResponse response,
Map<String, MultipartFile> filesById,
ProgressListener listener) {
List<AiFile> filesToConvert = response.getFilesToIngest();
if (filesToConvert == null || filesToConvert.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested markdown conversion without listing any files."));
}
try {
List<Resource> resultFiles = new ArrayList<>();
List<String> inputNames = new ArrayList<>();
for (int i = 0; i < filesToConvert.size(); i++) {
AiFile file = filesToConvert.get(i);
MultipartFile multipartFile = filesById.get(file.getId());
if (multipartFile == null) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested markdown conversion for unknown file: "
+ file.getName()));
}
listener.onProgress(
AiWorkflowProgressEvent.executingTool(
PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
Resource input = toResource(multipartFile);
PipelineDefinition definition =
new PipelineDefinition(
"convert-markdown",
List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
null);
PolicyExecutionResult result =
policyExecutor.execute(
definition,
PolicyInputs.of(List.of(input)),
PolicyProgressListener.NOOP);
resultFiles.addAll(result.files());
inputNames.add(multipartFile.getOriginalFilename());
}
return new WorkflowState.Terminal(
buildCompletedResponse(null, resultFiles, inputNames, null));
} catch (InternalApiTimeoutException e) {
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
return new WorkflowState.Terminal(
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
} catch (Exception e) {
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
}
}
private Resource toResource(MultipartFile file) throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
return new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
};
}
private void ingestFile(AiFile file, MultipartFile multipartFile) throws IOException {
List<AiPageText> pages = new ArrayList<>();
try (PDDocument document = pdfDocumentFactory.load(multipartFile, true)) {
@@ -551,16 +624,7 @@ public class AiWorkflowService {
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
List<Resource> resources = new ArrayList<>();
for (MultipartFile file : filesById.values()) {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
resources.add(
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
});
resources.add(toResource(file));
}
return resources;
}
@@ -30,11 +30,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.pdf.parser.PageImageLocator;
import stirling.software.SPDF.pdf.parser.PdfIngester;
import stirling.software.SPDF.pdf.parser.PdfModels.ParsedPage;
import stirling.software.SPDF.pdf.parser.PdfModels.RawLine;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.PdfModels.TextFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
@@ -50,7 +46,6 @@ import stirling.software.proprietary.model.api.ai.FolioType;
public class PdfContentExtractor {
private final TabulaTableParser tabulaTableParser;
private final PdfIngester pdfIngester;
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
@@ -196,8 +191,6 @@ public class PdfContentExtractor {
case PAGE_TEXT, FULL_TEXT ->
Optional.<PdfContentResult>ofNullable(
extractText(lf, fileReq, remainingPages, remainingCharacters));
case PAGE_LAYOUT ->
Optional.<PdfContentResult>ofNullable(extractPageLayout(lf, remainingPages));
default -> {
log.warn(
"Content type {} not yet implemented, skipping for {}",
@@ -222,35 +215,6 @@ public class PdfContentExtractor {
return extracted.isEmpty() ? null : buildExtractedFileText(lf.fileName(), extracted);
}
private PageLayoutFileResult extractPageLayout(LoadedFile lf, int maxPages) throws IOException {
List<ParsedPage> parsedPages = pdfIngester.parse(lf.document(), maxPages);
List<LayoutPage> pages = new ArrayList<>();
for (ParsedPage pp : parsedPages) {
if (pp.layoutLines().isEmpty()) continue;
List<LayoutLine> lines = new ArrayList<>();
for (RawLine rawLine : pp.layoutLines()) {
List<LayoutFragment> fragments = new ArrayList<>();
for (TextFragment tf : rawLine.fragments()) {
fragments.add(
new LayoutFragment(
tf.text(),
tf.bounds().x(),
tf.bounds().y(),
tf.bounds().width(),
tf.fontSize(),
tf.bold()));
}
lines.add(new LayoutLine(rawLine.bounds().y(), fragments));
}
pages.add(new LayoutPage(pp.pageNumber(), lines));
}
if (pages.isEmpty()) return null;
PageLayoutFileResult result = new PageLayoutFileResult();
result.setFileName(lf.fileName());
result.setPages(pages);
return result;
}
private WorkflowArtifact buildArtifact(ArtifactKind kind, List<PdfContentResult> results) {
return switch (kind) {
case EXTRACTED_TEXT -> {
@@ -258,11 +222,6 @@ public class PdfContentExtractor {
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
yield artifact;
}
case PAGE_LAYOUT -> {
PageLayoutArtifact artifact = new PageLayoutArtifact();
artifact.setFiles(results.stream().map(PageLayoutFileResult.class::cast).toList());
yield artifact;
}
case TOOL_REPORT ->
throw new IllegalArgumentException(
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
@@ -569,7 +528,6 @@ public class PdfContentExtractor {
*/
enum ArtifactKind {
EXTRACTED_TEXT("extracted_text"),
PAGE_LAYOUT("page_layout"),
TOOL_REPORT("tool_report");
private final String value;
@@ -633,40 +591,4 @@ public class PdfContentExtractor {
this.report = report;
}
}
// Serialization contract with the Python engine — see PageLayoutArtifactContractTest.
/** One text fragment with its bounding-box geometry and font properties. */
record LayoutFragment(
String text, float x, float y, float width, float fontSize, boolean bold) {}
/** A visual line on the page: y-coordinate and all fragments on that line. */
record LayoutLine(float y, List<LayoutFragment> fragments) {}
/** All layout lines for a single page. */
record LayoutPage(int pageNumber, List<LayoutLine> lines) {}
/** Page layout data for one file, as a PdfContentResult. */
@Data
static final class PageLayoutFileResult implements PdfContentResult {
private String fileName;
private List<LayoutPage> pages = new ArrayList<>();
@Override
public ArtifactKind getArtifactKind() {
return ArtifactKind.PAGE_LAYOUT;
}
@Override
public int pagesConsumed() {
return pages.size();
}
}
/** Artifact carrying full spatial page layout for all input files. */
@Data
static final class PageLayoutArtifact implements WorkflowArtifact {
private final ArtifactKind kind = ArtifactKind.PAGE_LAYOUT;
private List<PageLayoutFileResult> files = new ArrayList<>();
}
}