Skip to content

Commit 9863ece

Browse files
committed
remove streaming parser
Signed-off-by: Radek Ježek <radek.jezek@ibm.com>
1 parent 7838528 commit 9863ece

9 files changed

Lines changed: 240 additions & 800 deletions

File tree

apps/agentstack-server/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies = [
4747
"mcp>=1.13.1",
4848
"opentelemetry-instrumentation-httpx>=0.59b0",
4949
"opentelemetry-instrumentation-fastapi>=0.59b0",
50+
"ijson>=3.4.0.post0",
5051
]
5152

5253
[dependency-groups]

apps/agentstack-server/src/agentstack_server/domain/models/file.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from collections.abc import Awaitable, Callable
4+
from collections.abc import AsyncIterator, Awaitable, Callable
55
from enum import StrEnum
6+
from typing import Self
67
from uuid import UUID, uuid4
78

89
from pydantic import AwareDatetime, BaseModel, Field
@@ -55,6 +56,36 @@ class AsyncFile(BaseModel):
5556
read: Callable[[int], Awaitable[bytes]]
5657
size: int | None = None
5758

59+
@classmethod
60+
def from_async_iterator(cls, iterator: AsyncIterator[bytes], filename: str, content_type: str) -> Self:
61+
buffer = b""
62+
63+
async def read(size: int = 8192) -> bytes:
64+
nonlocal buffer
65+
while len(buffer) < size:
66+
try:
67+
buffer += await anext(iterator)
68+
except StopAsyncIteration:
69+
break
70+
71+
result = buffer[:size]
72+
buffer = buffer[size:]
73+
return result
74+
75+
return cls(filename=filename, content_type=content_type, read=read)
76+
77+
@classmethod
78+
def from_bytes(cls, content: bytes, filename: str, content_type: str) -> Self:
79+
pos = 0
80+
81+
async def read(size: int = 8192) -> bytes:
82+
nonlocal pos
83+
result = content[pos : pos + size]
84+
pos += len(result)
85+
return result
86+
87+
return cls(filename=filename, content_type=content_type, read=read, size=len(content))
88+
5889

5990
class File(BaseModel):
6091
id: UUID = Field(default_factory=uuid4)

apps/agentstack-server/src/agentstack_server/domain/repositories/file.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,5 @@ async def extract_text(
8989
file_url: AnyUrl,
9090
timeout: timedelta | None = None, # noqa: ASYNC109
9191
settings: TextExtractionSettings | None = None,
92-
) -> AsyncIterator[list[tuple[AsyncFile, ExtractionFormat]]]:
92+
) -> AsyncIterator[AsyncIterator[tuple[AsyncFile, ExtractionFormat]]]:
9393
yield ... # pyright: ignore [reportReturnType]

apps/agentstack-server/src/agentstack_server/infrastructure/text_extraction/docling.py

Lines changed: 52 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,66 @@
55
from collections.abc import AsyncIterator
66
from contextlib import asynccontextmanager
77
from 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
1014
from pydantic import AnyUrl
1115

1216
from agentstack_server.configuration import DoclingExtractionConfiguration
1317
from agentstack_server.domain.models.file import AsyncFile, ExtractionFormat, TextExtractionSettings
1418
from agentstack_server.domain.repositories.file import ITextExtractionBackend
15-
from agentstack_server.utils.utils import StreamingExtractor, build_stream_reader
1619

1720
logger = 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

4361
class 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)

apps/agentstack-server/src/agentstack_server/service_layer/services/files.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ async def extract_text(self, file_id: UUID, job_id: str):
9191
async with self._extraction_backend.extract_text(
9292
file_url=file_url,
9393
settings=extraction.extraction_metadata.settings if extraction.extraction_metadata else None,
94-
) as extracted_files_with_format:
94+
) as extracted_files_iterator:
9595
extracted_files = []
96-
for async_file, extraction_format in extracted_files_with_format:
96+
async for async_file, extraction_format in extracted_files_iterator:
9797
extracted_db_file = await self.upload_file(
9898
file=async_file,
9999
user=user,

0 commit comments

Comments
 (0)