55from collections .abc import AsyncIterator
66from contextlib import asynccontextmanager
77from datetime import timedelta
8+ from decimal import Decimal
9+ from typing import NamedTuple , cast
810
9- from httpx import AsyncClient
11+ import ijson
12+ import orjson
13+ from httpx import AsyncClient as HttpxAsyncClient
1014from pydantic import AnyUrl
1115
1216from agentstack_server .configuration import DoclingExtractionConfiguration
1317from agentstack_server .domain .models .file import AsyncFile , ExtractionFormat , TextExtractionSettings
1418from agentstack_server .domain .repositories .file import ITextExtractionBackend
15- from agentstack_server .utils .utils import StreamingExtractor , build_stream_reader
1619
1720logger = logging .getLogger (__name__ )
1821
1922
20- class DoclingFormatInfo :
21- """Docling-specific format information."""
23+ class DoclingFormatInfo (NamedTuple ):
24+ api_option : str
25+ file_extension : str
26+ response_field_key : str
27+ content_type : str
2228
23- def __init__ (self , format_value : str , field_key : str , content_type : str ):
24- self .format_value = format_value # The format value for the API (e.g., "md", "json")
25- self .field_key = field_key # The JSON response field key (e.g., "md_content", "json_content")
26- self .content_type = content_type # The MIME type for the file
27- self .is_object = content_type == "application/json" # Whether the field contains a JSON object
2829
30+ _DOCLING_FORMAT_INFO : dict [ExtractionFormat , DoclingFormatInfo ] = {
31+ ExtractionFormat .MARKDOWN : DoclingFormatInfo ("md" , "md" , "md_content" , "text/markdown" ),
32+ ExtractionFormat .VENDOR_SPECIFIC_JSON : DoclingFormatInfo ("json" , "json" , "json_content" , "application/json" ),
33+ }
2934
30- def _get_docling_format_info (format : ExtractionFormat ) -> DoclingFormatInfo :
31- """Get docling-specific format information for an ExtractionFormat."""
32- mapping = {
33- ExtractionFormat .MARKDOWN : DoclingFormatInfo (
34- format_value = "md" , field_key = "md_content" , content_type = "text/markdown"
35- ),
36- ExtractionFormat .VENDOR_SPECIFIC_JSON : DoclingFormatInfo (
37- format_value = "json" , field_key = "json_content" , content_type = "application/json"
38- ),
39- }
40- return mapping [format ]
35+
36+ async def _process_docling_stream (
37+ async_file : AsyncFile , formats : list [ExtractionFormat ]
38+ ) -> AsyncIterator [tuple [AsyncFile , ExtractionFormat ]]:
39+ key_map = {info .response_field_key : (fmt , info ) for fmt , info in _DOCLING_FORMAT_INFO .items () if fmt in formats }
40+
41+ def serialize (obj ):
42+ if isinstance (obj , Decimal ):
43+ return float (obj )
44+ raise TypeError
45+
46+ async for k , v in ijson .kvitems_async (async_file , "document" , use_float = False ): # pyright: ignore[reportAny]
47+ if k in key_map :
48+ fmt , info = key_map [k ]
49+
50+ content = v .encode ("utf-8" ) if isinstance (v , str ) else cast (bytes , orjson .dumps (v , default = serialize )) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
51+
52+ async_file = AsyncFile .from_bytes (
53+ filename = f"extracted_response.{ info .file_extension } " ,
54+ content_type = info .content_type ,
55+ content = content ,
56+ )
57+
58+ yield (async_file , fmt )
4159
4260
4361class DoclingTextExtractionBackend (ITextExtractionBackend ):
62+ """
63+ # TODO: [DISCLAIMER] this is loading entire field value to memory (which in case of docling is entire document)
64+ # Implementing a streaming parser has been deemed too complex, instead the text extraction worker should be
65+ # scaled independently with memory bounded by concurrency and max_single_file_size configuration
66+ """
67+
4468 def __init__ (self , config : DoclingExtractionConfiguration ):
4569 self ._config = config
4670 self ._enabled = config .enabled
@@ -57,82 +81,35 @@ async def extract_text(
5781 file_url : AnyUrl ,
5882 timeout : timedelta | None = None , # noqa: ASYNC109
5983 settings : TextExtractionSettings | None = None ,
60- ) -> AsyncIterator [list [tuple [AsyncFile , ExtractionFormat ]]]:
84+ ) -> AsyncIterator [AsyncIterator [tuple [AsyncFile , ExtractionFormat ]]]:
6185 """
6286 Extract text from a file using the Docling service.
63-
64- IMPORTANT: This method MUST be used as an async context manager. The returned AsyncFile
65- objects stream data directly from the HTTP response and must be consumed INSIDE the
66- context block:
67-
68- async with backend.extract_text(file_url) as files:
69- for async_file, format in files:
70- # Read and process files HERE, inside the context
71- data = await async_file.read()
72- # Files are no longer readable after context exits
73-
74- Do NOT store AsyncFile references outside the context - they will fail to read with
75- "StreamingExtractor is closed" error.
76-
77- Args:
78- file_url: URL of the file to extract text from
79- timeout: Maximum time to wait for extraction (default: 5 minutes)
80- settings: Optional extraction settings (formats to extract)
81-
82- Yields:
83- List of (AsyncFile, ExtractionFormat) tuples containing extracted content
84-
85- Raises:
86- RuntimeError: If Docling extraction backend is not enabled
87- HTTPError: If Docling service returns an error
87+ Streams the response and yields files as they are parsed.
8888 """
8989 if not self ._enabled :
9090 raise RuntimeError (
9191 "Docling extraction backend is not enabled, please check the documentation how to enable it"
9292 )
9393
94- # Exports to both markdown and vendor-specific JSON by default
9594 formats = settings .formats if settings else [ExtractionFormat .MARKDOWN , ExtractionFormat .VENDOR_SPECIFIC_JSON ]
9695
97- # Get docling-specific format information for all requested formats
98- # Keep pairs of (format_info, extraction_format) to maintain association
99- format_pairs = [(fmt , _get_docling_format_info (fmt )) for fmt in formats ]
100- to_formats = [info .format_value for _ , info in format_pairs ]
101-
10296 timeout = timeout or timedelta (minutes = 5 )
97+
10398 async with (
104- AsyncClient (base_url = str (self ._config .docling_service_url ), timeout = timeout .seconds ) as client ,
99+ HttpxAsyncClient (base_url = str (self ._config .docling_service_url ), timeout = timeout .seconds ) as client ,
105100 client .stream (
106101 "POST" ,
107102 "/v1/convert/source" ,
108103 json = {
109104 "options" : {
110- "to_formats" : to_formats ,
105+ "to_formats" : [ _DOCLING_FORMAT_INFO [ fmt ]. api_option for fmt in formats ] ,
111106 "document_timeout" : timeout .total_seconds (),
112107 "image_export_mode" : "placeholder" ,
113108 },
114109 "sources" : [{"kind" : "http" , "url" : str (file_url )}],
115110 },
116111 ) as response ,
117112 ):
118- response .raise_for_status ()
119-
120- # Build a dict of keys to extract with their types
121- keys_to_extract = {info .field_key : "object" if info .is_object else "string" for _ , info in format_pairs }
122-
123- # Create streaming extractor with context manager for proper cleanup
124- async with StreamingExtractor (response .aiter_text , keys_to_extract ) as extractor :
125- iterators = extractor .get_iterators ()
126-
127- # Create (AsyncFile, ExtractionFormat) tuples for each format
128- files = []
129- for extraction_format , info in format_pairs :
130- async_file = AsyncFile (
131- filename = f"extracted_response.{ info .format_value } " ,
132- content_type = info .content_type ,
133- read = build_stream_reader (iterators [info .field_key ]),
134- size = None , # Size unknown when streaming
135- )
136- files .append ((async_file , extraction_format ))
137-
138- yield files
113+ response .raise_for_status () # pyright: ignore[reportUnusedCallResult]
114+ async_file = AsyncFile .from_async_iterator (response .aiter_bytes (), "tmp" , "application/json" )
115+ yield _process_docling_stream (async_file , formats )
0 commit comments