mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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<>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user