mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Feature/pdf ingestion jpdfium (#6525)
This commit is contained in:
+2
-1
@@ -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;
|
||||
|
||||
|
||||
+74
-10
@@ -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;
|
||||
}
|
||||
|
||||
-78
@@ -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<>();
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -439,6 +439,35 @@ class AiWorkflowServiceTest {
|
||||
verify(internalApiClient, never()).post(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
|
||||
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
|
||||
when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{
|
||||
"outcome":"convert_markdown",
|
||||
"reason":"PDF to Markdown requested.",
|
||||
"filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
|
||||
}
|
||||
""");
|
||||
when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
|
||||
.thenReturn(false);
|
||||
stubEndpoint(
|
||||
"/api/v1/convert/pdf/markdown",
|
||||
pdfResource("# Title", "multi-column-test_lorem.md"));
|
||||
AtomicInteger ids = stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
|
||||
|
||||
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
|
||||
assertEquals(1, result.getResultFiles().size());
|
||||
// Extension changes (pdf -> md), so the converter's response filename wins.
|
||||
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
|
||||
assertEquals(1, ids.get());
|
||||
verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
|
||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LayoutFragment;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LayoutLine;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LayoutPage;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PageLayoutArtifact;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PageLayoutFileResult;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Contract test: verifies that {@link PageLayoutArtifact} serializes to the JSON field names that
|
||||
* the Python engine expects in {@code engine/src/stirling/contracts/pdf_to_markdown.py}.
|
||||
*
|
||||
* <p>The companion Python test in {@code tests/test_pdf_to_markdown.py} deserializes the same JSON
|
||||
* literal and asserts field values. If either side renames a field, one of these tests fails.
|
||||
*/
|
||||
class PageLayoutArtifactContractTest {
|
||||
|
||||
static final String CONTRACT_JSON =
|
||||
"""
|
||||
{"kind":"page_layout","files":[{"fileName":"test.pdf","pages":[{"pageNumber":1,"lines":[{"y":10.0,"fragments":[{"text":"Hello","x":1.0,"y":2.0,"width":30.0,"fontSize":12.0,"bold":true}]}]}]}]}""";
|
||||
|
||||
@Test
|
||||
void pageLayoutArtifact_serialisesToExpectedJson() throws Exception {
|
||||
LayoutFragment fragment = new LayoutFragment("Hello", 1.0f, 2.0f, 30.0f, 12.0f, true);
|
||||
LayoutLine line = new LayoutLine(10.0f, List.of(fragment));
|
||||
LayoutPage page = new LayoutPage(1, List.of(line));
|
||||
|
||||
PageLayoutFileResult fileResult = new PageLayoutFileResult();
|
||||
fileResult.setFileName("test.pdf");
|
||||
fileResult.setPages(List.of(page));
|
||||
|
||||
PageLayoutArtifact artifact = new PageLayoutArtifact();
|
||||
artifact.setFiles(List.of(fileResult));
|
||||
|
||||
JsonNode json = new JsonMapper().valueToTree(artifact);
|
||||
|
||||
assertEquals("page_layout", json.get("kind").asText());
|
||||
|
||||
JsonNode file = json.get("files").get(0);
|
||||
assertEquals("test.pdf", file.get("fileName").asText());
|
||||
|
||||
JsonNode pg = file.get("pages").get(0);
|
||||
assertEquals(1, pg.get("pageNumber").asInt());
|
||||
|
||||
JsonNode ln = pg.get("lines").get(0);
|
||||
assertEquals(10.0, ln.get("y").asDouble(), 0.001);
|
||||
|
||||
JsonNode frag = ln.get("fragments").get(0);
|
||||
assertEquals("Hello", frag.get("text").asText());
|
||||
assertEquals(1.0, frag.get("x").asDouble(), 0.001);
|
||||
assertEquals(2.0, frag.get("y").asDouble(), 0.001);
|
||||
assertEquals(30.0, frag.get("width").asDouble(), 0.001);
|
||||
assertEquals(12.0, frag.get("fontSize").asDouble(), 0.001);
|
||||
assertTrue(frag.get("bold").asBoolean());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user