Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions unstract/sdk1/src/unstract/sdk1/adapters/x2text/dto.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,76 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from unstract.sdk1.file_storage import FileStorageProvider


@dataclass
class TextExtractionMetadata:
whisper_hash: str
line_metadata: dict[Any, Any] | None = None
# Optional, additive field populated only in image output mode. Defaults to
# None so existing text-mode consumers are entirely unaffected (the field is
# never encoded into ``extracted_text``). See PageImageReference below.
page_images: list[PageImageReference] | None = None


@dataclass
class TextExtractionResult:
extracted_text: str
extraction_metadata: TextExtractionMetadata | None = None


@dataclass
class PageImageReference:
"""Per-page image reference for image-mode extraction results.

Produced by the LLMWhisperer image output mode: each entry points to a
single page image that has been persisted to Unstract's FileStorage. This
is a dedicated value object so image references are never smuggled inside
the string ``extracted_text`` field used by text-mode consumers.

Attributes:
page_number: 1-based index of the page this image represents.
path: FileStorage path / reference string to the stored page image.
filename: Stored image filename (e.g. ``page_001.png``). Optional.
size_bytes: Size of the stored image file in bytes. Optional.
provider: FileStorageProvider backend (LOCAL/S3/...) holding the
image. Optional.
"""

page_number: int
path: str
filename: str | None = None
size_bytes: int | None = None
provider: FileStorageProvider | None = None

def to_dict(self) -> dict[str, Any]:
"""Serialize to a plain, JSON-friendly dictionary.

The ``provider`` enum is stored as its string value so the result is
directly serializable; ``from_dict`` reverses this.
"""
return {
"page_number": self.page_number,
"path": self.path,
"filename": self.filename,
"size_bytes": self.size_bytes,
"provider": self.provider.value if self.provider is not None else None,
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> PageImageReference:
"""Reconstruct a PageImageReference from ``to_dict`` output.

Round-trips with ``to_dict``: ``from_dict(ref.to_dict()) == ref``.
"""
provider = data.get("provider")
return cls(
page_number=data["page_number"],
path=data["path"],
filename=data.get("filename"),
size_bytes=data.get("size_bytes"),
provider=FileStorageProvider(provider) if provider is not None else None,
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Modes(Enum):
class OutputModes(Enum):
LAYOUT_PRESERVING = "layout_preserving"
TEXT = "text"
IMAGE = "image"


class HTTPMethod(Enum):
Expand All @@ -31,6 +32,12 @@ class WhispererEndpoint:
STATUS = "whisper-status"
RETRIEVE = "whisper-retrieve"
HIGHLIGHTS = "highlights"
# Image output mode (pdf-to-images) endpoints. These are NOT exposed by the
# llmwhisperer-client package, so the adapter calls them via raw requests
# (decision 2A). See ImageOutputConfig for the assumed service contract.
PDF_TO_IMAGES = "pdf-to-images"
PDF_TO_IMAGES_STATUS = "pdf-to-images-status"
PDF_TO_IMAGES_RETRIEVE = "pdf-to-images-retrieve"


class WhispererEnv:
Expand All @@ -47,6 +54,16 @@ class WhispererEnv:
MAX_RETRIES = "ADAPTER_LLMW_MAX_RETRIES"
RETRY_MIN_WAIT = "ADAPTER_LLMW_RETRY_MIN_WAIT"
RETRY_MAX_WAIT = "ADAPTER_LLMW_RETRY_MAX_WAIT"
# Max retry attempts for per-page FileStorage writes when persisting page
# images (image output mode). Applies to Unstract-side storage writes only,
# not to calls made to the LLMWhisperer service.
PAGE_STORE_MAX_RETRIES = "ADAPTER_LLMW_PAGE_STORE_MAX_RETRIES"
# Image output mode HTTP tuning. Submit/status calls use a short timeout;
# the ZIP download uses a distinct, longer timeout (large multi-page PDFs).
IMAGE_REQUEST_TIMEOUT = "ADAPTER_LLMW_IMAGE_REQUEST_TIMEOUT"
IMAGE_DOWNLOAD_TIMEOUT = "ADAPTER_LLMW_IMAGE_DOWNLOAD_TIMEOUT"
IMAGE_POLL_INTERVAL = "ADAPTER_LLMW_IMAGE_POLL_INTERVAL"
IMAGE_POLL_MAX_ATTEMPTS = "ADAPTER_LLMW_IMAGE_POLL_MAX_ATTEMPTS"
LOG_LEVEL = "LOG_LEVEL"


Expand Down Expand Up @@ -114,3 +131,68 @@ class WhispererDefaults:
MAX_RETRIES = int(os.getenv(WhispererEnv.MAX_RETRIES, 3))
RETRY_MIN_WAIT = float(os.getenv(WhispererEnv.RETRY_MIN_WAIT, 1.0))
RETRY_MAX_WAIT = float(os.getenv(WhispererEnv.RETRY_MAX_WAIT, 60.0))
PAGE_STORE_MAX_RETRIES = int(os.getenv(WhispererEnv.PAGE_STORE_MAX_RETRIES, 3))
IMAGE_REQUEST_TIMEOUT = int(os.getenv(WhispererEnv.IMAGE_REQUEST_TIMEOUT, 30))
IMAGE_DOWNLOAD_TIMEOUT = int(os.getenv(WhispererEnv.IMAGE_DOWNLOAD_TIMEOUT, 300))
IMAGE_POLL_INTERVAL = float(os.getenv(WhispererEnv.IMAGE_POLL_INTERVAL, 3.0))
IMAGE_POLL_MAX_ATTEMPTS = int(os.getenv(WhispererEnv.IMAGE_POLL_MAX_ATTEMPTS, 100))


class ImageOutputConfig:
"""Config and service contract for LLMWhisperer image output mode.

CONTRACT SOURCE: verified against LLMWhisperer Service **PR #536** (branch
``image-output``; PR #647 is a sub-fix). The endpoints are NOT exposed by
the installed ``llmwhisperer-client``, so the adapter calls them via raw
``requests`` (decision 2A). Everything the adapter relies on is centralised
here.

Flow (raw ``requests``, base = ``{url}/api/v2``):

- Submit: ``POST {base}/pdf-to-images?format=png`` with the PDF bytes
-> JSON ``{"message": "...", "status": "processing",
"whisper_hash": "<run_id>|<data_hash>"}`` (HTTP 202)
- Status: ``GET {base}/pdf-to-images-status?whisper_hash=<id>``
-> JSON ``{"status": "accepted|processing|processed|...",
"message": "..."}``. NOTE: no page count is exposed
(page count is billing-internal only).
- Retrieve: ``GET {base}/pdf-to-images-retrieve?whisper_hash=<id>``
-> ``application/zip`` stream of ``page_001.png``, ...
ONE-TIME by default: the service flips status to ``RETRIEVED``
before streaming and rejects a second retrieve unless the
deployment sets ``RESULT_PERSISTENCE=true``. Hence the adapter
downloads exactly once and never retries the retrieve.
"""

# --- Response field names ---
STATUS = "status"
# Not currently returned by pdf-to-images-status (billing-internal). Kept as
# a forward-compatible hook for verify_page_count().
PROCESSED_PAGE_COUNT = "processed_page_count"
MESSAGE = "message"

# Terminal service states. Ready-to-retrieve == PROCESSED (WhisperStatus).
STATUS_SUCCESS = frozenset({"processed"})
STATUS_FAILURE = frozenset({"error", "failed", "unknown"})

# --- Submit query params ---
IMAGE_FORMAT_PARAM = "format"
DEFAULT_IMAGE_FORMAT = "png"
FILE_NAME_PARAM = "file_name"

# --- Per-page image naming / storage layout ---
PAGE_IMAGE_PREFIX = "page_"
PAGE_IMAGE_EXTENSION = ".png"
PAGE_NUMBER_PADDING = 3
PAGES_SUBFOLDER = "pages"

# --- UI / validation (single source of truth) ---
# Display label for the image output mode option (UNS-754).
IMAGE_MODE_LABEL = "Image (PDF only)"
PDF_EXTENSION = ".pdf"
# Shared by runtime (process) and UI validation so the message is identical
# regardless of where the PDF-only check fires (UNS-757).
PDF_ONLY_ERROR = (
"Image output mode supports PDF input only. "
"Please provide a PDF file or select a text output mode."
)
Loading