Skip to content

Commit 6631de2

Browse files
dolfim-ibmkdinklacoderabbitai[bot]autofix-ci[bot]
authored
feat: Docling components (#8394)
* initial DoclingComponent Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * Correct Docling icon style properties. Signed-off-by: DKL <dkl@zurich.ibm.com> * add file_path Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * add load from json and export to various formats Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * add chunking component Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * Update src/backend/base/langflow/components/docling/docling_inline.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add Docling Serve component Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * apply some suggestions Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * Update src/backend/base/langflow/components/docling/_utils.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/backend/base/langflow/components/docling/docling_remote.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add check for DoclingDocument in list Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * fix import Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * add maximum poll timeout and better checks for the retry logic Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * add updated starter_projects Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * refactor _get_converter Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * return only DataFrame Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * remove LoadDoclingDocument Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * more options in the chunk component Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * move docling imports Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> * [autofix.ci] apply automated fixes * move utils to langflow.base Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> --------- Signed-off-by: Michele Dolfi <dol@zurich.ibm.com> Signed-off-by: DKL <dkl@zurich.ibm.com> Co-authored-by: DKL <dkl@zurich.ibm.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 5b5f1dd commit 6631de2

15 files changed

Lines changed: 1837 additions & 151 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ dependencies = [
124124
"cleanlab-tlm>=1.1.2",
125125
'gassist>=0.0.1; sys_platform == "win32"',
126126
"twelvelabs>=0.4.7",
127+
"docling>=2.36.1",
127128
]
128129

129130
[dependency-groups]
@@ -219,6 +220,12 @@ postgresql = [
219220
"sqlalchemy[postgresql_psycopg]>=2.0.38,<3.0.0",
220221
]
221222

223+
[tool.uv]
224+
override-dependencies = [
225+
# temporary force a newer python-pptx
226+
"python-pptx>=1.0.2"
227+
]
228+
222229
[project.scripts]
223230
langflow = "langflow.__main__:main"
224231

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from docling_core.types.doc import DoclingDocument
2+
3+
from langflow.schema.data import Data
4+
from langflow.schema.dataframe import DataFrame
5+
6+
7+
def extract_docling_documents(data_inputs: Data | list[Data] | DataFrame, doc_key: str) -> list[DoclingDocument]:
8+
documents: list[DoclingDocument] = []
9+
if isinstance(data_inputs, DataFrame):
10+
if not len(data_inputs):
11+
msg = "DataFrame is empty"
12+
raise TypeError(msg)
13+
14+
if doc_key not in data_inputs.columns:
15+
msg = f"Column '{doc_key}' not found in DataFrame"
16+
raise TypeError(msg)
17+
try:
18+
documents = data_inputs[doc_key].tolist()
19+
except Exception as e:
20+
msg = f"Error extracting DoclingDocument from DataFrame: {e}"
21+
raise TypeError(msg) from e
22+
else:
23+
if not data_inputs:
24+
msg = "No data inputs provided"
25+
raise TypeError(msg)
26+
27+
if isinstance(data_inputs, Data):
28+
if doc_key not in data_inputs.data:
29+
msg = f"{doc_key} field not available in the input Data"
30+
raise TypeError(msg)
31+
documents = [data_inputs.data[doc_key]]
32+
else:
33+
try:
34+
documents = [
35+
input_.data[doc_key]
36+
for input_ in data_inputs
37+
if isinstance(input_, Data)
38+
and doc_key in input_.data
39+
and isinstance(input_.data[doc_key], DoclingDocument)
40+
]
41+
if not documents:
42+
msg = f"No valid Data inputs found in {type(data_inputs)}"
43+
raise TypeError(msg)
44+
except AttributeError as e:
45+
msg = f"Invalid input type in collection: {e}"
46+
raise TypeError(msg) from e
47+
return documents
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .chunk_docling_document import ChunkDoclingDocumentComponent
2+
from .docling_inline import DoclingInlineComponent
3+
from .docling_remote import DoclingRemoteComponent
4+
from .export_docling_document import ExportDoclingDocumentComponent
5+
6+
__all__ = [
7+
"ChunkDoclingDocumentComponent",
8+
"DoclingInlineComponent",
9+
"DoclingRemoteComponent",
10+
"ExportDoclingDocumentComponent",
11+
]
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import json
2+
3+
import tiktoken
4+
from docling_core.transforms.chunker import BaseChunker, DocMeta
5+
from docling_core.transforms.chunker.hierarchical_chunker import HierarchicalChunker
6+
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
7+
from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
8+
from docling_core.transforms.chunker.tokenizer.openai import OpenAITokenizer
9+
10+
from langflow.base.data.docling_utils import extract_docling_documents
11+
from langflow.custom import Component
12+
from langflow.io import DropdownInput, HandleInput, IntInput, MessageTextInput, Output, StrInput
13+
from langflow.schema import Data, DataFrame
14+
15+
16+
class ChunkDoclingDocumentComponent(Component):
17+
display_name: str = "Chunk DoclingDocument"
18+
description: str = "Use the DocumentDocument chunkers to split the document into chunks."
19+
documentation = "https://docling-project.github.io/docling/concepts/chunking/"
20+
icon = "Docling"
21+
name = "ChunkDoclingDocument"
22+
23+
inputs = [
24+
HandleInput(
25+
name="data_inputs",
26+
display_name="Data or DataFrame",
27+
info="The data with documents to split in chunks.",
28+
input_types=["Data", "DataFrame"],
29+
required=True,
30+
),
31+
DropdownInput(
32+
name="chunker",
33+
display_name="Chunker",
34+
options=["HybridChunker", "HierarchicalChunker"],
35+
info=("Which chunker to use."),
36+
value="HybridChunker",
37+
real_time_refresh=True,
38+
),
39+
DropdownInput(
40+
name="provider",
41+
display_name="Provider",
42+
options=["Hugging Face", "OpenAI"],
43+
info=("Which tokenizer provider."),
44+
value="Hugging Face",
45+
show=True,
46+
real_time_refresh=True,
47+
advanced=True,
48+
dynamic=True,
49+
),
50+
StrInput(
51+
name="hf_model_name",
52+
display_name="HF model name",
53+
info=(
54+
"Model name of the tokenizer to use with the HybridChunker when Hugging Face is chosen as a tokenizer."
55+
),
56+
value="sentence-transformers/all-MiniLM-L6-v2",
57+
show=True,
58+
advanced=True,
59+
dynamic=True,
60+
),
61+
StrInput(
62+
name="openai_model_name",
63+
display_name="OpenAI model name",
64+
info=("Model name of the tokenizer to use with the HybridChunker when OpenAI is chosen as a tokenizer."),
65+
value="gpt-4o",
66+
show=False,
67+
advanced=True,
68+
dynamic=True,
69+
),
70+
IntInput(
71+
name="max_tokens",
72+
display_name="Maximum tokens",
73+
info=("Maximum number of tokens for the HybridChunker."),
74+
show=True,
75+
required=False,
76+
advanced=True,
77+
dynamic=True,
78+
),
79+
MessageTextInput(
80+
name="doc_key",
81+
display_name="Doc Key",
82+
info="The key to use for the DoclingDocument column.",
83+
value="doc",
84+
advanced=True,
85+
),
86+
]
87+
88+
outputs = [
89+
Output(display_name="DataFrame", name="dataframe", method="chunk_documents"),
90+
]
91+
92+
def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:
93+
if field_name == "chunker":
94+
provider_type = build_config["provider"]["value"]
95+
is_hf = provider_type == "Hugging Face"
96+
is_openai = provider_type == "OpenAI"
97+
if field_value == "HybridChunker":
98+
build_config["provider"]["show"] = True
99+
build_config["hf_model_name"]["show"] = is_hf
100+
build_config["openai_model_name"]["show"] = is_openai
101+
build_config["max_tokens"]["show"] = True
102+
else:
103+
build_config["provider"]["show"] = False
104+
build_config["hf_model_name"]["show"] = False
105+
build_config["openai_model_name"]["show"] = False
106+
build_config["max_tokens"]["show"] = False
107+
elif field_name == "provider" and build_config["chunker"]["value"] == "HybridChunker":
108+
if field_value == "Hugging Face":
109+
build_config["hf_model_name"]["show"] = True
110+
build_config["openai_model_name"]["show"] = False
111+
elif field_value == "OpenAI":
112+
build_config["hf_model_name"]["show"] = False
113+
build_config["openai_model_name"]["show"] = True
114+
115+
return build_config
116+
117+
def _docs_to_data(self, docs) -> list[Data]:
118+
return [Data(text=doc.page_content, data=doc.metadata) for doc in docs]
119+
120+
def chunk_documents(self) -> DataFrame:
121+
documents = extract_docling_documents(self.data_inputs, self.doc_key)
122+
123+
chunker: BaseChunker
124+
if self.chunker == "HybridChunker":
125+
max_tokens: int | None = self.max_tokens if self.max_tokens else None
126+
if self.provider == "Hugging Face":
127+
tokenizer = HuggingFaceTokenizer.from_pretrained(
128+
model_name=self.hf_model_name,
129+
max_tokens=max_tokens,
130+
)
131+
elif self.provider == "OpenAI":
132+
if max_tokens is None:
133+
max_tokens = 128 * 1024 # context window length required for OpenAI tokenizers
134+
tokenizer = OpenAITokenizer(
135+
tokenizer=tiktoken.encoding_for_model(self.openai_model_name), max_tokens=max_tokens
136+
)
137+
chunker = HybridChunker(
138+
tokenizer=tokenizer,
139+
)
140+
elif self.chunker == "HierarchicalChunker":
141+
chunker = HierarchicalChunker()
142+
143+
results: list[Data] = []
144+
try:
145+
for doc in documents:
146+
for chunk in chunker.chunk(dl_doc=doc):
147+
enriched_text = chunker.contextualize(chunk=chunk)
148+
meta = DocMeta.model_validate(chunk.meta)
149+
150+
results.append(
151+
Data(
152+
data={
153+
"text": enriched_text,
154+
"document_id": f"{doc.origin.binary_hash}",
155+
"doc_items": json.dumps([item.self_ref for item in meta.doc_items]),
156+
}
157+
)
158+
)
159+
160+
except Exception as e:
161+
msg = f"Error splitting text: {e}"
162+
raise TypeError(msg) from e
163+
164+
return DataFrame(results)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from docling.datamodel.base_models import ConversionStatus, InputFormat
2+
from docling.datamodel.pipeline_options import (
3+
OcrOptions,
4+
PdfPipelineOptions,
5+
VlmPipelineOptions,
6+
)
7+
from docling.document_converter import DocumentConverter, FormatOption, PdfFormatOption
8+
from docling.models.factories import get_ocr_factory
9+
from docling.pipeline.vlm_pipeline import VlmPipeline
10+
11+
from langflow.base.data import BaseFileComponent
12+
from langflow.inputs import DropdownInput
13+
from langflow.schema import Data
14+
15+
16+
class DoclingInlineComponent(BaseFileComponent):
17+
display_name = "Docling"
18+
description = "Uses Docling to process input documents running the Docling models locally."
19+
documentation = "https://docling-project.github.io/docling/"
20+
trace_type = "tool"
21+
icon = "Docling"
22+
name = "DoclingInline"
23+
24+
# https://docling-project.github.io/docling/usage/supported_formats/
25+
VALID_EXTENSIONS = [
26+
"adoc",
27+
"asciidoc",
28+
"asc",
29+
"bmp",
30+
"csv",
31+
"dotx",
32+
"dotm",
33+
"docm",
34+
"docx",
35+
"htm",
36+
"html",
37+
"jpeg",
38+
"json",
39+
"md",
40+
"pdf",
41+
"png",
42+
"potx",
43+
"ppsx",
44+
"pptm",
45+
"potm",
46+
"ppsm",
47+
"pptx",
48+
"tiff",
49+
"txt",
50+
"xls",
51+
"xlsx",
52+
"xhtml",
53+
"xml",
54+
"webp",
55+
]
56+
57+
inputs = [
58+
*BaseFileComponent._base_inputs,
59+
DropdownInput(
60+
name="pipeline",
61+
display_name="Pipeline",
62+
info="Docling pipeline to use",
63+
options=["standard", "vlm"],
64+
real_time_refresh=False,
65+
value="standard",
66+
),
67+
DropdownInput(
68+
name="ocr_engine",
69+
display_name="Ocr",
70+
info="OCR engine to use",
71+
options=["", "easyocr", "tesserocr", "rapidocr", "ocrmac"],
72+
real_time_refresh=False,
73+
value="",
74+
),
75+
# TODO: expose more Docling options
76+
]
77+
78+
outputs = [
79+
*BaseFileComponent._base_outputs,
80+
]
81+
82+
def process_files(self, file_list: list[BaseFileComponent.BaseFile]) -> list[BaseFileComponent.BaseFile]:
83+
# Configure the standard PDF pipeline
84+
def _get_standard_opts() -> PdfPipelineOptions:
85+
pipeline_options = PdfPipelineOptions()
86+
pipeline_options.do_ocr = self.ocr_engine != ""
87+
if pipeline_options.do_ocr:
88+
ocr_factory = get_ocr_factory(
89+
allow_external_plugins=False,
90+
)
91+
92+
ocr_options: OcrOptions = ocr_factory.create_options(
93+
kind=self.ocr_engine,
94+
)
95+
pipeline_options.ocr_options = ocr_options
96+
return pipeline_options
97+
98+
# Configure the VLM pipeline
99+
def _get_vlm_opts() -> VlmPipelineOptions:
100+
return VlmPipelineOptions()
101+
102+
# Configure the main format options and create the DocumentConverter()
103+
def _get_converter() -> DocumentConverter:
104+
if self.pipeline == "standard":
105+
pdf_format_option = PdfFormatOption(
106+
pipeline_options=_get_standard_opts(),
107+
)
108+
elif self.pipeline == "vlm":
109+
pdf_format_option = PdfFormatOption(pipeline_cls=VlmPipeline, pipeline_options=_get_vlm_opts())
110+
111+
format_options: dict[InputFormat, FormatOption] = {
112+
InputFormat.PDF: pdf_format_option,
113+
InputFormat.IMAGE: pdf_format_option,
114+
}
115+
116+
return DocumentConverter(format_options=format_options)
117+
118+
file_paths = [file.path for file in file_list if file.path]
119+
120+
if not file_paths:
121+
self.log("No files to process.")
122+
return file_list
123+
124+
converter = _get_converter()
125+
results = converter.convert_all(file_paths)
126+
127+
processed_data: list[Data | None] = [
128+
Data(data={"doc": res.document, "file_path": str(res.input.file)})
129+
if res.status == ConversionStatus.SUCCESS
130+
else None
131+
for res in results
132+
]
133+
134+
return self.rollup_data(file_list, processed_data)

0 commit comments

Comments
 (0)