Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class X2TextConstants:
X2TEXT_PORT = "X2TEXT_PORT"
ENABLE_HIGHLIGHT = "enable_highlight"
TAGS = "tags"
FILE_NAME = "file_name"
EXTRACTED_TEXT = "extracted_text"
WHISPER_HASH = "whisper-hash"
WHISPER_HASH_V2 = "whisper_hash"
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class WhispererConfig:
PAGE_SEPARATOR = "page_seperator"
URL_IN_POST = "url_in_post"
TAG = "tag"
FILENAME = "filename"
USE_WEBHOOK = "use_webhook"
WEBHOOK_METADATA = "webhook_metadata"
TEXT_ONLY = "text_only"
Expand Down Expand Up @@ -107,6 +108,7 @@ class WhispererDefaults:
MARK_HORIZONTAL_LINES = False
URL_IN_POST = False
TAG = "default"
FILENAME = ""
TEXT_ONLY = False
WAIT_TIMEOUT = int(os.getenv(WhispererEnv.WAIT_TIMEOUT, 900))
WAIT_FOR_COMPLETION = True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ class WhispererRequestParams:
List[str] or str.
Will be converted to str or None after initialization.
enable_highlight (bool): Whether to enable highlighting. Defaults to False.
filename (Optional[str]): Original name of the document being extracted.
Sent to LLMWhisperer so its reports identify the source file instead
of the execution's internal filename.
"""

# TODO: Extend this DTO to include all Whisperer API parameters
tag: str | list[str] | None = None
enable_highlight: bool = False
filename: str | None = None

def __post_init__(self) -> None:
"""Post-initialization processing for LLMWhisperer V2 request data."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ def get_whisperer_params(
WhispererConfig.TAG,
WhispererDefaults.TAG,
),
# Name of the source document, surfaced in LLMWhisperer's reports.
# The upload is streamed from the execution's internal file
# (INFILE), so without this a whisper call cannot be traced back
# to the document it extracted.
WhispererConfig.FILENAME: extra_params.filename
or WhispererDefaults.FILENAME,
WhispererConfig.USE_WEBHOOK: config.get(WhispererConfig.USE_WEBHOOK, ""),
WhispererConfig.WEBHOOK_METADATA: config.get(
WhispererConfig.WEBHOOK_METADATA
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def process(
extra_params = WhispererRequestParams(
tag=kwargs.get(X2TextConstants.TAGS),
enable_highlight=enable_highlight,
filename=kwargs.get(X2TextConstants.FILE_NAME),
)
response: requests.Response = LLMWhispererHelper.send_whisper_request(
input_file_path=input_file_path,
Expand Down
13 changes: 12 additions & 1 deletion unstract/sdk1/src/unstract/sdk1/x2txt.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from unstract.sdk1.adapters.x2text.x2text_adapter import X2TextAdapter
from unstract.sdk1.audit import Audit
from unstract.sdk1.constants import Common as SdkCommon
from unstract.sdk1.constants import LogLevel, MimeType, ToolEnv
from unstract.sdk1.constants import LogLevel, MimeType, ToolEnv, UsageKwargs
from unstract.sdk1.exceptions import X2TextError
from unstract.sdk1.file_storage import FileStorage, FileStorageProvider
from unstract.sdk1.platform import PlatformHelper
Expand Down Expand Up @@ -95,6 +95,16 @@ def _get_x2text(self) -> X2TextAdapter:
f"Error getting text extractor '{adapter_info}': {e}"
) from e

def _source_file_name(self) -> str | None:
"""Name of the document as the user knows it.

Execution copies the input to an internal file (``INFILE``), so the path
reaching the adapter carries no usable name. The original is tracked in
``usage_kwargs``, which every caller already populates for usage
auditing.
"""
return self._usage_kwargs.get(UsageKwargs.FILE_NAME)

def process(
self,
input_file_path: str,
Expand All @@ -104,6 +114,7 @@ def process(
) -> TextExtractionResult:
if fs is None:
fs = FileStorage(provider=FileStorageProvider.LOCAL)
kwargs.setdefault(X2TextConstants.FILE_NAME, self._source_file_name())
mime_type = fs.mime_type(input_file_path)
text_extraction_result: TextExtractionResult = None
if mime_type == MimeType.TEXT:
Expand Down
148 changes: 148 additions & 0 deletions unstract/sdk1/tests/test_x2text_source_filename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Tests for the source filename sent to LLMWhisperer (UN-3142).

Execution streams the document from its internal copy (``INFILE``), so the path
that reaches the adapter carries no usable name and LLMWhisperer's reports had
nothing to identify the call by. The real name travels in ``usage_kwargs``,
which every caller already populates, and is forwarded as the client's
``filename`` param.

Pins:
- ``X2Text.process`` injects the name from ``usage_kwargs``
- an explicit ``file_name`` kwarg wins over ``usage_kwargs``
- the adapter forwards it into ``WhispererRequestParams``
- ``get_whisperer_params`` emits it as ``filename``, defaulting to empty
- injecting it does not disturb the existing ``tag`` behaviour
"""

from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from unstract.sdk1.adapters.x2text.constants import X2TextConstants
from unstract.sdk1.adapters.x2text.dto import (
TextExtractionMetadata,
TextExtractionResult,
)
from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src.constants import (
WhispererConfig,
)
from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src.dto import (
WhispererRequestParams,
)
from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src.helper import (
LLMWhispererHelper,
)
from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src.llm_whisperer_v2 import (
LLMWhispererV2,
)
from unstract.sdk1.constants import UsageKwargs
from unstract.sdk1.file_storage import FileStorage
from unstract.sdk1.x2txt import X2Text


def _make_x2text(usage_kwargs: dict[str, Any]) -> X2Text:
"""Build an X2Text with adapter initialisation bypassed."""
x2text = X2Text.__new__(X2Text)
x2text._tool = MagicMock()
x2text._usage_kwargs = usage_kwargs
x2text._x2text_instance = MagicMock()
x2text._x2text_instance.process.return_value = TextExtractionResult(
extracted_text="text",
extraction_metadata=TextExtractionMetadata(whisper_hash="h-1"),
)
return x2text


@pytest.fixture
def mock_fs() -> MagicMock:
fs = MagicMock()
fs.mime_type.return_value = "application/pdf"
return fs


class TestX2TextInjectsSourceFilename:
@patch.object(X2Text, "push_usage_details", MagicMock())
def test_filename_taken_from_usage_kwargs(self, mock_fs: MagicMock) -> None:
x2text = _make_x2text({UsageKwargs.FILE_NAME: "invoice-2024.pdf"})

x2text.process(input_file_path="/data/exec/abc/INFILE", fs=mock_fs)

kwargs = x2text._x2text_instance.process.call_args.kwargs
assert kwargs[X2TextConstants.FILE_NAME] == "invoice-2024.pdf"

@patch.object(X2Text, "push_usage_details", MagicMock())
def test_explicit_kwarg_wins_over_usage_kwargs(self, mock_fs: MagicMock) -> None:
"""The agentic path passes the name directly rather than via usage."""
x2text = _make_x2text({UsageKwargs.FILE_NAME: "from-usage.pdf"})

x2text.process(
input_file_path="/data/exec/abc/INFILE",
fs=mock_fs,
file_name="explicit.pdf",
)

kwargs = x2text._x2text_instance.process.call_args.kwargs
assert kwargs[X2TextConstants.FILE_NAME] == "explicit.pdf"

@patch.object(X2Text, "push_usage_details", MagicMock())
def test_absent_usage_kwargs_yields_none(self, mock_fs: MagicMock) -> None:
"""No name available must not raise — LLMWhisperer just gets the default."""
x2text = _make_x2text({})

x2text.process(input_file_path="/data/exec/abc/INFILE", fs=mock_fs)

kwargs = x2text._x2text_instance.process.call_args.kwargs
assert kwargs[X2TextConstants.FILE_NAME] is None


class TestAdapterForwardsFilename:
def test_process_forwards_filename_to_request_params(self) -> None:
adapter = LLMWhispererV2(settings={})
captured: dict[str, WhispererRequestParams] = {}

def _capture(
input_file_path: str,
config: dict[str, Any],
extra_params: WhispererRequestParams,
fs: FileStorage | None = None,
) -> dict[str, Any]:
captured["params"] = extra_params
return {"result_text": "text", "whisper_hash": "h-1"}

with patch.object(
LLMWhispererHelper, "send_whisper_request", side_effect=_capture
):
adapter.process(
input_file_path="/data/exec/abc/INFILE",
fs=MagicMock(),
**{X2TextConstants.FILE_NAME: "statement.pdf"},
)

assert captured["params"].filename == "statement.pdf"


class TestWhispererParams:
def test_filename_included_in_query_params(self) -> None:
params = LLMWhispererHelper.get_whisperer_params(
config={},
extra_params=WhispererRequestParams(filename="contract.pdf"),
)

assert params[WhispererConfig.FILENAME] == "contract.pdf"

def test_filename_defaults_to_empty_when_unknown(self) -> None:
params = LLMWhispererHelper.get_whisperer_params(
config={}, extra_params=WhispererRequestParams()
)

assert params[WhispererConfig.FILENAME] == ""

def test_tag_behaviour_unchanged(self) -> None:
"""Filename must not disturb the tag, which carries customer tags."""
params = LLMWhispererHelper.get_whisperer_params(
config={},
extra_params=WhispererRequestParams(tag=["customer-tag"], filename="doc.pdf"),
)

assert params[WhispererConfig.TAG] == "customer-tag"
assert params[WhispererConfig.FILENAME] == "doc.pdf"
61 changes: 50 additions & 11 deletions workers/executor/executors/legacy_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
extraction, summarisation, and usage tracking.
"""

import json
import logging
import time
from pathlib import Path
Expand Down Expand Up @@ -186,7 +187,7 @@
# Phase 2B — Extract handler
# ------------------------------------------------------------------

def _handle_extract(self, context: ExecutionContext) -> ExecutionResult:

Check failure on line 190 in workers/executor/executors/legacy_executor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaANublQ1IT-AH4Uod6l&open=AaANublQ1IT-AH4Uod6l&pullRequest=2246
"""Handle ``Operation.EXTRACT`` — text extraction via x2text.

Migrated from ``ExtractionService.perform_extraction()`` in
Expand Down Expand Up @@ -259,13 +260,6 @@
tags=tags,
fs=fs,
)
self._update_exec_metadata(
fs=fs,
execution_source=execution_source,
tool_exec_metadata=tool_exec_metadata,
execution_data_dir=execution_data_dir,
process_response=process_response,
)
else:
process_response = x2text.process(
input_file_path=file_path,
Expand All @@ -274,6 +268,25 @@
fs=fs,
)

# The whisper hash identifies the LLMWhisperer call that produced
# this text, and is the handle used to correlate an execution with
# that call. Capture it for every extraction that returns one — not
# just highlighted ones, which is how it used to work and why it was
# missing from most executions.
whisper_hash = (
process_response.extraction_metadata.whisper_hash
if process_response.extraction_metadata
else None
)
if whisper_hash:
self._update_exec_metadata(
fs=fs,
execution_source=execution_source,
tool_exec_metadata=tool_exec_metadata,
execution_data_dir=execution_data_dir,
whisper_hash=whisper_hash,
)

has_metadata = bool(
process_response.extraction_metadata
and process_response.extraction_metadata.line_metadata
Expand All @@ -292,7 +305,12 @@
Path(file_path).name,
context.run_id,
)
shim.stream_log("Text extraction completed")
# Surfaced to the user so an execution can be matched to the
# LLMWhisperer call behind it without digging through service logs.
shim.stream_log(
"Text extraction completed"
+ (f" (whisper hash: {whisper_hash})" if whisper_hash else "")
)
result_data: dict[str, Any] = {
IKeys.EXTRACTED_TEXT: process_response.extracted_text,
}
Expand Down Expand Up @@ -326,20 +344,41 @@
execution_source: str,
tool_exec_metadata: dict[str, Any] | None,
execution_data_dir: str | None,
process_response: TextExtractionResult,
whisper_hash: str,
) -> None:
"""Write whisper_hash metadata for tool-sourced executions."""
if execution_source != ExecutionSource.TOOL.value:
return
whisper_hash = process_response.extraction_metadata.whisper_hash
metadata = {X2TextConstants.WHISPER_HASH: whisper_hash}
if tool_exec_metadata is not None:
for key, value in metadata.items():
tool_exec_metadata[key] = value
if not execution_data_dir:
return
metadata_path = str(Path(execution_data_dir) / IKeys.METADATA_FILE)
# METADATA.json is shared with the rest of the execution — source name,
# source hash and tool results are written to it before and after
# extraction. Merge into it rather than overwriting, which would drop
# everything already recorded there.
existing: dict[str, Any] = {}
try:
if fs.exists(metadata_path):
raw = fs.read(path=metadata_path, mode="r")
if raw:
existing = json.loads(raw)
except Exception:
logger.warning(
"Could not read existing metadata at '%s'; writing whisper hash only",
metadata_path,
exc_info=True,
)
existing = {}
if not isinstance(existing, dict):
existing = {}
existing.update(metadata)
ToolUtils.dump_json(
file_to_dump=metadata_path,
json_to_dump=metadata,
json_to_dump=existing,
fs=fs,
)

Expand Down
1 change: 1 addition & 0 deletions workers/file_processing/structure_tool_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ def _run_agentic_extraction(
extraction_result = x2text.process(
input_file_path=input_file_path,
enable_highlight=enable_highlight,
file_name=source_file_name,
fs=fs,
)
Comment on lines 725 to 730

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Agentic path drops whisper hash

When an agentic studio tool extracts a document through _run_agentic_extraction, this direct X2Text call consumes only extracted_text and discards extraction_metadata.whisper_hash, causing the hash to be absent from METADATA.json and customer-facing execution logs.

Knowledge Base Used: Workers (Celery) Service

Prompt To Fix With AI
This is a comment left during a code review.
Path: workers/file_processing/structure_tool_task.py
Line: 725-730

Comment:
**Agentic path drops whisper hash**

When an agentic studio tool extracts a document through `_run_agentic_extraction`, this direct `X2Text` call consumes only `extracted_text` and discards `extraction_metadata.whisper_hash`, causing the hash to be absent from `METADATA.json` and customer-facing execution logs.

**Knowledge Base Used:** [Workers (Celery) Service](https://app.greptile.com/zipstack/-/custom-context/knowledge-base/zipstack/unstract/-/docs/workers-celery.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

document_text = extraction_result.extracted_text
Expand Down
Loading
Loading