Skip to content

Commit b7efc72

Browse files
committed
fix: make extract_text streamable again
Signed-off-by: Aleš Kalfas <kalfas.ales@gmail.com>
1 parent 89a4357 commit b7efc72

6 files changed

Lines changed: 477 additions & 180 deletions

File tree

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

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from agentstack_server.configuration import DoclingExtractionConfiguration
1313
from agentstack_server.domain.models.file import AsyncFile, ExtractionFormat, TextExtractionSettings
1414
from agentstack_server.domain.repositories.file import ITextExtractionBackend
15-
from agentstack_server.utils.utils import extract_multiple_values_stream
15+
from agentstack_server.utils.utils import StreamingExtractor, build_stream_reader
1616

1717
logger = logging.getLogger(__name__)
1818

@@ -58,6 +58,34 @@ async def extract_text(
5858
timeout: timedelta | None = None, # noqa: ASYNC109
5959
settings: TextExtractionSettings | None = None,
6060
) -> AsyncIterator[list[tuple[AsyncFile, ExtractionFormat]]]:
61+
"""
62+
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
88+
"""
6189
if not self._enabled:
6290
raise RuntimeError(
6391
"Docling extraction backend is not enabled, please check the documentation how to enable it"
@@ -92,34 +120,19 @@ async def extract_text(
92120
# Build a dict of keys to extract with their types
93121
keys_to_extract = {info.field_key: "object" if info.is_object else "string" for _, info in format_pairs}
94122

95-
# Extract all requested formats in a single pass through the stream
96-
extracted_contents = await extract_multiple_values_stream(response.aiter_text, keys_to_extract)
97-
98-
# Create (AsyncFile, ExtractionFormat) tuples for each format
99-
files = []
100-
for extraction_format, info in format_pairs:
101-
content = extracted_contents[info.field_key]
102-
103-
# Create a read function that returns the buffered content
104-
def create_read_fn(buffered_content: bytes):
105-
position = 0
106-
107-
async def read(chunk_size: int = 1024) -> bytes:
108-
nonlocal position
109-
if position >= len(buffered_content):
110-
return b""
111-
chunk = buffered_content[position : position + chunk_size]
112-
position += len(chunk)
113-
return chunk
114-
115-
return read
116-
117-
async_file = AsyncFile(
118-
filename=f"extracted_response.{info.format_value}",
119-
content_type=info.content_type,
120-
read=create_read_fn(content),
121-
size=len(content),
122-
)
123-
files.append((async_file, extraction_format))
124-
125-
yield files
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

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ def __init__(
5151
self._extraction_backend = extraction_backend
5252

5353
async def extract_text(self, file_id: UUID, job_id: str):
54+
"""
55+
Extract text from a file using the configured extraction backend.
56+
57+
This method coordinates the entire extraction process: fetching the file,
58+
extracting text, and uploading the extracted content.
59+
60+
Args:
61+
file_id: ID of the file to extract text from
62+
job_id: Background job ID for tracking
63+
64+
Raises:
65+
CancelledError: If the job is cancelled
66+
Exception: For any extraction or upload errors
67+
"""
5468
error_log = []
5569
async with self._uow() as uow:
5670
extraction = await uow.files.get_extraction_by_file_id(file_id=file_id)
@@ -67,6 +81,7 @@ async def extract_text(self, file_id: UUID, job_id: str):
6781
try:
6882
file_url = await self._object_storage.get_file_url(file_id=file_id)
6983
error_log.append(f"file url: {file_url}")
84+
7085
async with self._extraction_backend.extract_text(
7186
file_url=file_url,
7287
settings=extraction.extraction_metadata.settings if extraction.extraction_metadata else None,
@@ -81,6 +96,7 @@ async def extract_text(self, file_id: UUID, job_id: str):
8196
parent_file_id=file_id,
8297
)
8398
extracted_files.append(ExtractedFileInfo(file_id=extracted_db_file.id, format=extraction_format))
99+
84100
extraction.set_completed(extracted_files=extracted_files)
85101
async with self._uow() as uow:
86102
await uow.files.update_extraction(extraction=extraction)

0 commit comments

Comments
 (0)