diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/constants.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/constants.py index bd703e6538..e46fa78b3f 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/constants.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/constants.py @@ -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" diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/constants.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/constants.py index 090a3bf6f4..99fffbbbe8 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/constants.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/constants.py @@ -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" @@ -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 diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/dto.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/dto.py index 2bf5665e37..cf495b0931 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/dto.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/dto.py @@ -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.""" diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/helper.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/helper.py index ade89f7cba..21d0c27c40 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/helper.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/helper.py @@ -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 diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/llm_whisperer_v2.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/llm_whisperer_v2.py index 88e464d74f..ef022673df 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/llm_whisperer_v2.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/llm_whisperer_v2.py @@ -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, diff --git a/unstract/sdk1/src/unstract/sdk1/x2txt.py b/unstract/sdk1/src/unstract/sdk1/x2txt.py index 2024f8cdbc..44e9ff1fa2 100644 --- a/unstract/sdk1/src/unstract/sdk1/x2txt.py +++ b/unstract/sdk1/src/unstract/sdk1/x2txt.py @@ -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 @@ -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, @@ -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: diff --git a/unstract/sdk1/tests/test_x2text_source_filename.py b/unstract/sdk1/tests/test_x2text_source_filename.py new file mode 100644 index 0000000000..4632db93ee --- /dev/null +++ b/unstract/sdk1/tests/test_x2text_source_filename.py @@ -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" diff --git a/workers/executor/executors/legacy_executor.py b/workers/executor/executors/legacy_executor.py index b672d56ab6..27c6113970 100644 --- a/workers/executor/executors/legacy_executor.py +++ b/workers/executor/executors/legacy_executor.py @@ -5,6 +5,7 @@ extraction, summarisation, and usage tracking. """ +import json import logging import time from pathlib import Path @@ -259,13 +260,6 @@ def _handle_extract(self, context: ExecutionContext) -> ExecutionResult: 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, @@ -274,6 +268,25 @@ def _handle_extract(self, context: ExecutionContext) -> ExecutionResult: 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 @@ -292,7 +305,12 @@ def _handle_extract(self, context: ExecutionContext) -> ExecutionResult: 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, } @@ -326,20 +344,41 @@ def _update_exec_metadata( 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, ) diff --git a/workers/file_processing/structure_tool_task.py b/workers/file_processing/structure_tool_task.py index 971a783980..3545805f75 100644 --- a/workers/file_processing/structure_tool_task.py +++ b/workers/file_processing/structure_tool_task.py @@ -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, ) document_text = extraction_result.extracted_text diff --git a/workers/tests/test_legacy_executor_extract.py b/workers/tests/test_legacy_executor_extract.py index 0711d2255a..3cff63e038 100644 --- a/workers/tests/test_legacy_executor_extract.py +++ b/workers/tests/test_legacy_executor_extract.py @@ -592,3 +592,274 @@ def _raise_err(ctx): assert result.success is False assert result.error == "custom error" + + +# --- 12. Whisper hash capture and reporting (UN-3142) --- + + +class TestWhisperHashTracing: + """The whisper hash is the handle that ties an execution to the + LLMWhisperer call behind it. It used to be captured only when highlight + was enabled, so it was missing from most executions. + """ + + @patch("executor.executors.legacy_executor.ToolUtils.dump_json") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_hash_captured_when_highlight_disabled( + self, mock_x2text_cls, mock_get_fs, mock_dump + ): + from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 + + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response( + whisper_hash="whash-no-highlight" + ) + mock_x2text.x2text_instance = MagicMock(spec=LLMWhispererV2) + mock_x2text_cls.return_value = mock_x2text + mock_get_fs.return_value = MagicMock() + + tool_meta = {} + ctx = _make_context( + execution_source="tool", + executor_params={ + "x2text_instance_id": "x2t-whisperer", + "file_path": "/data/test.pdf", + "platform_api_key": "sk-key", + "enable_highlight": False, + "execution_data_dir": "/run/data", + "tool_execution_metadata": tool_meta, + }, + ) + result = executor.execute(ctx) + + assert result.success is True + # Highlight is off, but the hash must still be recorded + mock_dump.assert_called_once() + assert mock_dump.call_args.kwargs["json_to_dump"] == { + X2TextConstants.WHISPER_HASH: "whash-no-highlight" + } + assert tool_meta[X2TextConstants.WHISPER_HASH] == "whash-no-highlight" + + @patch("executor.executors.legacy_executor.ToolUtils.dump_json") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_extractor_without_hash_writes_no_metadata( + self, mock_x2text_cls, mock_get_fs, mock_dump + ): + """Non-Whisperer extractors return no hash — nothing to record.""" + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response(whisper_hash=None) + mock_x2text.x2text_instance = MagicMock() + mock_x2text_cls.return_value = mock_x2text + mock_get_fs.return_value = MagicMock() + + ctx = _make_context( + execution_source="tool", + executor_params={ + "x2text_instance_id": "x2t-generic", + "file_path": "/data/test.pdf", + "platform_api_key": "sk-key", + "execution_data_dir": "/run/data", + }, + ) + result = executor.execute(ctx) + + assert result.success is True + mock_dump.assert_not_called() + + @patch("executor.executors.legacy_executor.ToolUtils.dump_json") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_missing_execution_data_dir_does_not_crash( + self, mock_x2text_cls, mock_get_fs, mock_dump + ): + """No data dir means nowhere to dump, but the run must still succeed + and the in-memory metadata must still be populated. + """ + from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 + + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response( + whisper_hash="whash-789" + ) + mock_x2text.x2text_instance = MagicMock(spec=LLMWhispererV2) + mock_x2text_cls.return_value = mock_x2text + mock_get_fs.return_value = MagicMock() + + tool_meta = {} + ctx = _make_context( + execution_source="tool", + executor_params={ + "x2text_instance_id": "x2t-whisperer", + "file_path": "/data/test.pdf", + "platform_api_key": "sk-key", + "enable_highlight": True, + "tool_execution_metadata": tool_meta, + # no execution_data_dir + }, + ) + result = executor.execute(ctx) + + assert result.success is True + mock_dump.assert_not_called() + assert tool_meta[X2TextConstants.WHISPER_HASH] == "whash-789" + + @patch("executor.executors.legacy_executor.ExecutorToolShim") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_hash_reported_in_customer_facing_log( + self, mock_x2text_cls, mock_get_fs, mock_shim_cls + ): + from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 + + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response( + whisper_hash="whash-logged" + ) + mock_x2text.x2text_instance = MagicMock(spec=LLMWhispererV2) + mock_x2text_cls.return_value = mock_x2text + mock_get_fs.return_value = MagicMock() + mock_shim = MagicMock() + mock_shim_cls.return_value = mock_shim + + ctx = _make_context( + execution_source="ide", + executor_params={ + "x2text_instance_id": "x2t-whisperer", + "file_path": "/data/test.pdf", + "platform_api_key": "sk-key", + }, + ) + result = executor.execute(ctx) + + assert result.success is True + logged = " | ".join( + str(call.args[0]) for call in mock_shim.stream_log.call_args_list + ) + assert "whash-logged" in logged + + @patch("executor.executors.legacy_executor.ExecutorToolShim") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_completion_log_clean_without_hash( + self, mock_x2text_cls, mock_get_fs, mock_shim_cls + ): + """Extractors with no hash must not emit a dangling empty suffix.""" + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response(whisper_hash=None) + mock_x2text.x2text_instance = MagicMock() + mock_x2text_cls.return_value = mock_x2text + mock_get_fs.return_value = MagicMock() + mock_shim = MagicMock() + mock_shim_cls.return_value = mock_shim + + ctx = _make_context(execution_source="ide") + result = executor.execute(ctx) + + assert result.success is True + messages = [str(call.args[0]) for call in mock_shim.stream_log.call_args_list] + assert "Text extraction completed" in messages + + @patch("executor.executors.legacy_executor.ToolUtils.dump_json") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_existing_metadata_preserved(self, mock_x2text_cls, mock_get_fs, mock_dump): + """METADATA.json is shared — source name/hash written before extraction + must survive the whisper hash being recorded. + """ + import json as _json + + from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 + + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response( + whisper_hash="whash-merge" + ) + mock_x2text.x2text_instance = MagicMock(spec=LLMWhispererV2) + mock_x2text_cls.return_value = mock_x2text + + mock_fs = MagicMock() + mock_fs.exists.return_value = True + mock_fs.read.return_value = _json.dumps( + {"source_name": "invoice.pdf", "source_hash": "abc123"} + ) + mock_get_fs.return_value = mock_fs + + ctx = _make_context( + execution_source="tool", + executor_params={ + "x2text_instance_id": "x2t-whisperer", + "file_path": "/data/test.pdf", + "platform_api_key": "sk-key", + "enable_highlight": True, + "execution_data_dir": "/run/data", + }, + ) + result = executor.execute(ctx) + + assert result.success is True + dumped = mock_dump.call_args.kwargs["json_to_dump"] + assert dumped[X2TextConstants.WHISPER_HASH] == "whash-merge" + # Pre-existing keys must not be clobbered + assert dumped["source_name"] == "invoice.pdf" + assert dumped["source_hash"] == "abc123" + + @patch("executor.executors.legacy_executor.ToolUtils.dump_json") + @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") + @patch("executor.executors.legacy_executor.X2Text") + def test_unreadable_metadata_still_records_hash( + self, mock_x2text_cls, mock_get_fs, mock_dump + ): + """A corrupt METADATA.json must not fail the extraction.""" + from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 + + _register_legacy() + executor = ExecutorRegistry.get("legacy") + + mock_x2text = MagicMock() + mock_x2text.process.return_value = _mock_process_response( + whisper_hash="whash-corrupt" + ) + mock_x2text.x2text_instance = MagicMock(spec=LLMWhispererV2) + mock_x2text_cls.return_value = mock_x2text + + mock_fs = MagicMock() + mock_fs.exists.return_value = True + mock_fs.read.return_value = "{not valid json" + mock_get_fs.return_value = mock_fs + + ctx = _make_context( + execution_source="tool", + executor_params={ + "x2text_instance_id": "x2t-whisperer", + "file_path": "/data/test.pdf", + "platform_api_key": "sk-key", + "enable_highlight": True, + "execution_data_dir": "/run/data", + }, + ) + result = executor.execute(ctx) + + assert result.success is True + assert mock_dump.call_args.kwargs["json_to_dump"] == { + X2TextConstants.WHISPER_HASH: "whash-corrupt" + }