From 2481160c06c5f30a887ae158211cdae887953dfd Mon Sep 17 00:00:00 2001 From: Rahul Johny Date: Mon, 26 Oct 2026 15:05:47 +0530 Subject: [PATCH 1/3] feat: add include_cell_borders to whisper() LLMWhisperer draws an Excel workbook's own cell borders into the extracted text as box-drawing characters, and those cost tokens downstream: a small bordered sheet measures 273 characters with them and 113 without. include_cell_borders=false drops every border while keeping columns aligned and every cell value intact. The parameter keys off the uploaded file type, not the mode -- a spreadsheet routes to the Excel extractor under the default form mode as well as under excel, which is how most Excel uploads actually arrive -- and the service ignores it for every other kind of document. It is sent only when the caller sets it, like the other caller-only parameters above it: sending a default would pin a value the service would otherwise choose, and omitting it keeps this client working against deployments that predate the parameter. The spec hunk was produced by running upstream's own tools/gen_spec.py at the head of Zipstack/unstract-llm-whisperer#810, which adds the parameter to the controller but has not regenerated its spec yet. Re-sync byte-for-byte once that lands. Co-Authored-By: Claude Opus 5 --- specs/llmwhisperer.json | 9 ++++++++ .../_sdk_llmwhisperer/api/whisper/extract.py | 15 +++++++++++++ src/unstract/llmwhisperer/client_v2.py | 9 ++++++++ tests/unit/client_v2_test.py | 21 +++++++++++++++++++ tools/gen_sdk.sh | 7 ++++++- 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/specs/llmwhisperer.json b/specs/llmwhisperer.json index 1cd64d2..1664cd9 100644 --- a/specs/llmwhisperer.json +++ b/specs/llmwhisperer.json @@ -1589,6 +1589,15 @@ }, "x-internal": true }, + { + "in": "query", + "name": "include_cell_borders", + "required": false, + "schema": { + "default": true, + "type": "boolean" + } + }, { "in": "query", "name": "include_line_confidence", diff --git a/src/unstract/llmwhisperer/_sdk_llmwhisperer/api/whisper/extract.py b/src/unstract/llmwhisperer/_sdk_llmwhisperer/api/whisper/extract.py index e534036..f006a8d 100644 --- a/src/unstract/llmwhisperer/_sdk_llmwhisperer/api/whisper/extract.py +++ b/src/unstract/llmwhisperer/_sdk_llmwhisperer/api/whisper/extract.py @@ -25,6 +25,7 @@ def _get_kwargs( gaussian_blur_radius: float | Unset = 0.0, horizontal_stretch_factor: float | Unset = 1.0, ignore_vertical_text: bool | Unset = False, + include_cell_borders: bool | Unset = True, include_line_confidence: bool | Unset = False, lang: str | Unset = "eng", line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", @@ -66,6 +67,8 @@ def _get_kwargs( params["ignore_vertical_text"] = ignore_vertical_text + params["include_cell_borders"] = include_cell_borders + params["include_line_confidence"] = include_line_confidence params["lang"] = lang @@ -211,6 +214,7 @@ def sync_detailed( gaussian_blur_radius: float | Unset = 0.0, horizontal_stretch_factor: float | Unset = 1.0, ignore_vertical_text: bool | Unset = False, + include_cell_borders: bool | Unset = True, include_line_confidence: bool | Unset = False, lang: str | Unset = "eng", line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", @@ -243,6 +247,7 @@ def sync_detailed( gaussian_blur_radius (float | Unset): Default: 0.0. horizontal_stretch_factor (float | Unset): Default: 1.0. ignore_vertical_text (bool | Unset): Default: False. + include_cell_borders (bool | Unset): Default: True. include_line_confidence (bool | Unset): Default: False. lang (str | Unset): Default: 'eng'. line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. @@ -282,6 +287,7 @@ def sync_detailed( gaussian_blur_radius=gaussian_blur_radius, horizontal_stretch_factor=horizontal_stretch_factor, ignore_vertical_text=ignore_vertical_text, + include_cell_borders=include_cell_borders, include_line_confidence=include_line_confidence, lang=lang, line_splitter_strategy=line_splitter_strategy, @@ -323,6 +329,7 @@ def sync( gaussian_blur_radius: float | Unset = 0.0, horizontal_stretch_factor: float | Unset = 1.0, ignore_vertical_text: bool | Unset = False, + include_cell_borders: bool | Unset = True, include_line_confidence: bool | Unset = False, lang: str | Unset = "eng", line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", @@ -355,6 +362,7 @@ def sync( gaussian_blur_radius (float | Unset): Default: 0.0. horizontal_stretch_factor (float | Unset): Default: 1.0. ignore_vertical_text (bool | Unset): Default: False. + include_cell_borders (bool | Unset): Default: True. include_line_confidence (bool | Unset): Default: False. lang (str | Unset): Default: 'eng'. line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. @@ -395,6 +403,7 @@ def sync( gaussian_blur_radius=gaussian_blur_radius, horizontal_stretch_factor=horizontal_stretch_factor, ignore_vertical_text=ignore_vertical_text, + include_cell_borders=include_cell_borders, include_line_confidence=include_line_confidence, lang=lang, line_splitter_strategy=line_splitter_strategy, @@ -430,6 +439,7 @@ async def asyncio_detailed( gaussian_blur_radius: float | Unset = 0.0, horizontal_stretch_factor: float | Unset = 1.0, ignore_vertical_text: bool | Unset = False, + include_cell_borders: bool | Unset = True, include_line_confidence: bool | Unset = False, lang: str | Unset = "eng", line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", @@ -462,6 +472,7 @@ async def asyncio_detailed( gaussian_blur_radius (float | Unset): Default: 0.0. horizontal_stretch_factor (float | Unset): Default: 1.0. ignore_vertical_text (bool | Unset): Default: False. + include_cell_borders (bool | Unset): Default: True. include_line_confidence (bool | Unset): Default: False. lang (str | Unset): Default: 'eng'. line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. @@ -501,6 +512,7 @@ async def asyncio_detailed( gaussian_blur_radius=gaussian_blur_radius, horizontal_stretch_factor=horizontal_stretch_factor, ignore_vertical_text=ignore_vertical_text, + include_cell_borders=include_cell_borders, include_line_confidence=include_line_confidence, lang=lang, line_splitter_strategy=line_splitter_strategy, @@ -540,6 +552,7 @@ async def asyncio( gaussian_blur_radius: float | Unset = 0.0, horizontal_stretch_factor: float | Unset = 1.0, ignore_vertical_text: bool | Unset = False, + include_cell_borders: bool | Unset = True, include_line_confidence: bool | Unset = False, lang: str | Unset = "eng", line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", @@ -572,6 +585,7 @@ async def asyncio( gaussian_blur_radius (float | Unset): Default: 0.0. horizontal_stretch_factor (float | Unset): Default: 1.0. ignore_vertical_text (bool | Unset): Default: False. + include_cell_borders (bool | Unset): Default: True. include_line_confidence (bool | Unset): Default: False. lang (str | Unset): Default: 'eng'. line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. @@ -613,6 +627,7 @@ async def asyncio( gaussian_blur_radius=gaussian_blur_radius, horizontal_stretch_factor=horizontal_stretch_factor, ignore_vertical_text=ignore_vertical_text, + include_cell_borders=include_cell_borders, include_line_confidence=include_line_confidence, lang=lang, line_splitter_strategy=line_splitter_strategy, diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index 11502ca..8a49e20 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -87,6 +87,7 @@ "derotate_threshold", "checkbox_confidence_threshold", "min_table_width", + "include_cell_borders", # In URL mode the URL travels in the body; it is not also a query # parameter, so `url` is deliberately absent here. "url_in_post", @@ -703,6 +704,7 @@ def whisper( # noqa: C901 derotate_threshold: float | None = None, checkbox_confidence_threshold: float | None = None, min_table_width: float | None = None, + include_cell_borders: bool | None = None, ) -> Any: """Sends a request to the LLMWhisperer API to process a document. Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_api. @@ -770,6 +772,12 @@ def whisper( # noqa: C901 [0.0, 1.0]. Defaults to 0.3. min_table_width (float, optional): The minimum width a table must span, as a fraction of the page width, to be extracted as a table. Defaults to 0. + include_cell_borders (bool, optional): Whether an Excel workbook's own cell borders + are drawn into the extracted text as box-drawing characters. Turning it off drops + every border, leaving columns aligned and no cell value lost, which costs fewer + tokens downstream. This keys off the uploaded file type, not the mode: it applies + to any XLS or XLSX upload whatever mode was requested, the default "form" mode + included, and is ignored for every other kind of document. Defaults to True. Returns: Dict[Any, Any]: The response from the API as a dictionary. @@ -834,6 +842,7 @@ def whisper( # noqa: C901 ("derotate_threshold", derotate_threshold), ("checkbox_confidence_threshold", checkbox_confidence_threshold), ("min_table_width", min_table_width), + ("include_cell_borders", include_cell_borders), ) if value is not None } diff --git a/tests/unit/client_v2_test.py b/tests/unit/client_v2_test.py index 2296673..428b881 100644 --- a/tests/unit/client_v2_test.py +++ b/tests/unit/client_v2_test.py @@ -282,6 +282,27 @@ def test_whisper_rejects_both_spellings( _whisper_query(mocker, client_v2, **{deprecated_name: "x", name: "y"}) +# --- include_cell_borders tests --- + + +def test_whisper_omits_include_cell_borders_by_default(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: + """Unset means absent, so the service picks, and a deployment that predates + the parameter is unaffected.""" + query = _whisper_query(mocker, client_v2) + + assert "include_cell_borders" not in query + + +@pytest.mark.parametrize(("value", "expected"), [(False, "False"), (True, "True")]) +def test_whisper_sends_include_cell_borders_when_set( + mocker: MockerFixture, client_v2: LLMWhispererClientV2, value: bool, expected: str +) -> None: + """Either value reaches the query string once the caller asks for it.""" + query = _whisper_query(mocker, client_v2, include_cell_borders=value) + + assert query["include_cell_borders"] == [expected] + + # --- Retry behavior tests --- diff --git a/tools/gen_sdk.sh b/tools/gen_sdk.sh index d1299f4..4f2fffc 100755 --- a/tools/gen_sdk.sh +++ b/tools/gen_sdk.sh @@ -15,7 +15,12 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # here, so the next refresher can tell what they are replacing. SPEC_SOURCE_REPO="Zipstack/unstract-llm-whisperer" SPEC_SOURCE_PATH="specs/llmwhisperer.json" -SPEC_SOURCE_REV="c4cc7f1426612a5489f40c5948bb198f89f19c9f" +# Provisional: this is the head of the still-open upstream PR #810, which adds +# `include_cell_borders` to the controller but has not regenerated its own spec +# yet -- its spec-drift gate is red for exactly that reason. So the spec here was +# produced by running that revision's own tools/gen_spec.py, not copied from the +# file committed there. Re-sync byte-for-byte once #810 regenerates and lands. +SPEC_SOURCE_REV="23b6df511efb3892f7810ff75204d2b5446c9e54" VENV="$REPO/.gen-venv" # The generator shells out to ruff for its own post-processing. Without this it From 827a7e9a9a575ddeb0750740ab42460f596a93ca Mon Sep 17 00:00:00 2001 From: Rahul Johny Date: Mon, 26 Oct 2026 15:13:08 +0530 Subject: [PATCH 2/3] chore(spec): record the upstream commit that carries the spec #810 has since regenerated its own spec, and what it committed is byte-for-byte what running its generator at the earlier head produced -- so the vendored spec and the generated transport are both unchanged here. Only the recorded provenance moves, from the pre-regeneration head to db4cb15, and the note explaining that work-around goes with it. Co-Authored-By: Claude Opus 5 --- tools/gen_sdk.sh | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tools/gen_sdk.sh b/tools/gen_sdk.sh index 4f2fffc..305108a 100755 --- a/tools/gen_sdk.sh +++ b/tools/gen_sdk.sh @@ -15,12 +15,9 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # here, so the next refresher can tell what they are replacing. SPEC_SOURCE_REPO="Zipstack/unstract-llm-whisperer" SPEC_SOURCE_PATH="specs/llmwhisperer.json" -# Provisional: this is the head of the still-open upstream PR #810, which adds -# `include_cell_borders` to the controller but has not regenerated its own spec -# yet -- its spec-drift gate is red for exactly that reason. So the spec here was -# produced by running that revision's own tools/gen_spec.py, not copied from the -# file committed there. Re-sync byte-for-byte once #810 regenerates and lands. -SPEC_SOURCE_REV="23b6df511efb3892f7810ff75204d2b5446c9e54" +# The head of PR #810, which is approved but not merged yet, so this revision is +# not on main. Same as the #756 sync before it. +SPEC_SOURCE_REV="db4cb15cb1c9f5227a220b5900423471498bf0a8" VENV="$REPO/.gen-venv" # The generator shells out to ruff for its own post-processing. Without this it From e2ecd846811309c11e61da24eb4f00d0c43f1f64 Mon Sep 17 00:00:00 2001 From: Rahul Johny Date: Mon, 26 Oct 2026 15:22:58 +0530 Subject: [PATCH 3/3] chore(spec): follow #810 to the commit that is on main #810 was squash-merged, so the PR head recorded before it landed is not on main and will not be found by anyone retracing where this spec came from. The squash commit is, and its spec is identical to what is vendored here, so the spec and the generated transport are both unchanged -- only the recorded revision moves, and the note about the PR being unmerged goes with it. Co-Authored-By: Claude Opus 5 --- tools/gen_sdk.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/gen_sdk.sh b/tools/gen_sdk.sh index 305108a..45b1859 100755 --- a/tools/gen_sdk.sh +++ b/tools/gen_sdk.sh @@ -15,9 +15,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # here, so the next refresher can tell what they are replacing. SPEC_SOURCE_REPO="Zipstack/unstract-llm-whisperer" SPEC_SOURCE_PATH="specs/llmwhisperer.json" -# The head of PR #810, which is approved but not merged yet, so this revision is -# not on main. Same as the #756 sync before it. -SPEC_SOURCE_REV="db4cb15cb1c9f5227a220b5900423471498bf0a8" +SPEC_SOURCE_REV="9fe7085b44da6ca37e635fba3ab082c725ef8aef" VENV="$REPO/.gen-venv" # The generator shells out to ruff for its own post-processing. Without this it