Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6c23ac9
UN-2646 [FEAT] Add LLMWhisperer image output mode to the v2 adapter
pk-zipstack Jul 25, 2026
bfc473d
UN-2646 [FEAT] Conditional PDF-only guidance on image mode (UNS-759)
pk-zipstack Jul 25, 2026
2a505bd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 25, 2026
dce48a6
UN-2646 [FIX] Gate image PDF-only guidance to image mode (UNS-759)
pk-zipstack Jul 25, 2026
2269a57
UN-2646 [FEAT] Fail-fast PDF-only check before index dispatch (UNS-757)
pk-zipstack Jul 25, 2026
4f3fe2b
UN-2646 [FIX] Resolve SonarCloud findings on image-output code (UNS-758)
pk-zipstack Jul 27, 2026
9136161
Merge remote-tracking branch 'origin/main' into feat/llmwhisperer-ima…
pk-zipstack Jul 27, 2026
f4a92d6
Merge remote-tracking branch 'origin/feat/llmwhisperer-image-output-a…
pk-zipstack Jul 27, 2026
8d98bb5
UN-2646 [FIX] Address CodeRabbit/Greptile review on image-output helper
pk-zipstack Jul 27, 2026
9ec3538
UN-2646 [FIX] Make image-mode results survive indexing + cache (UNS-758)
pk-zipstack Jul 27, 2026
fac7836
UN-2646 [FIX] Keep image-mode cache fix, drop unused ref plumbing (UN…
pk-zipstack Jul 27, 2026
da9cf91
UN-2646 [FIX] Address Chandru's review on image-output mode
pk-zipstack Jul 27, 2026
3ac1aea
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 27, 2026
b98945c
UN-2646 [FIX] Drop remaining PR/ticket provenance from image-output c…
pk-zipstack Jul 27, 2026
4e619bb
UN-2646 [FEAT] Gate image output mode behind the cloud consumer plugin
pk-zipstack Jul 28, 2026
a92f347
UN-2646 [FEAT] OSS half of the VLM image-answer feature (path contrac…
pk-zipstack Aug 5, 2026
59bdaca
Merge branch 'main' into feat/llmwhisperer-image-output-adapter
pk-zipstack Aug 5, 2026
c8060ff
UN-2646 [FIX] Gate the platform fallback by execution source; fix CI …
pk-zipstack Aug 5, 2026
341701b
UN-2646 [FIX] Reset the stable pages dir before persisting a new page…
pk-zipstack Aug 5, 2026
3515124
UN-2646 [FIX] Stamp x2text output mode onto single-pass payloads
pk-zipstack Aug 6, 2026
1a55d49
Merge branch 'main' into feat/llmwhisperer-image-output-adapter
pk-zipstack Aug 6, 2026
3dc5832
UN-2646 [FIX] Fail closed when the VLM plugin is installed but broken
pk-zipstack Aug 6, 2026
b49587a
UN-2646 [FIX] Order invalidation before the success marker; propagate…
pk-zipstack Aug 6, 2026
92d85e5
[MISC] Retrigger CI: review-thread confirmations added for the two fl…
pk-zipstack Aug 6, 2026
c457331
UN-2646 [FIX] Verify the pages dir is empty after reset, not just tha…
pk-zipstack Aug 6, 2026
6df67c8
UN-2646 [DOCS] Document the accepted concurrency contract at the writ…
pk-zipstack Aug 6, 2026
178866f
[MISC] Retrigger review: flagged threads reopened with developer resp…
pk-zipstack Aug 6, 2026
5ac7565
UN-2646 [TEST] Pin the no-op-rm variant of the post-reset survivor guard
pk-zipstack Aug 6, 2026
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
10 changes: 8 additions & 2 deletions backend/adapter_processor_v2/adapter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
InValidAdapterId,
TestAdapterError,
)
from adapter_processor_v2.image_output_gating import (
filter_image_output_mode,
validate_image_output_allowed,
)
from unstract.sdk1.adapters.adapterkit import Adapterkit
from unstract.sdk1.adapters.base import Adapter
from unstract.sdk1.adapters.x2text.constants import X2TextConstants
Expand Down Expand Up @@ -43,8 +47,9 @@
AdapterKeys.ID, adapter_id
)
if len(updated_adapters) != 0:
schema_details[AdapterKeys.JSON_SCHEMA] = json.loads(
updated_adapters[0].get(AdapterKeys.JSON_SCHEMA)
schema_details[AdapterKeys.JSON_SCHEMA] = filter_image_output_mode(
adapter_id,
json.loads(updated_adapters[0].get(AdapterKeys.JSON_SCHEMA)),

Check warning on line 52 in backend/adapter_processor_v2/adapter_processor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Fix this "__getitem__" operation; Previous type checks suggest that "updated_adapters" does not have this method.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AZ-olOoXM5o_ow3fNYF7&open=AZ-olOoXM5o_ow3fNYF7&pullRequest=2210
)
else:
logger.error(f"Invalid adapter Id : {adapter_id} while fetching JSON Schema")
Expand Down Expand Up @@ -110,6 +115,7 @@

@staticmethod
def test_adapter(adapter_id: str, adapter_metadata: dict[str, Any]) -> bool:
validate_image_output_allowed(adapter_metadata, adapter_id)
try:
adapter_type = adapter_metadata.get(AdapterKeys.ADAPTER_TYPE)

Expand Down
122 changes: 122 additions & 0 deletions backend/adapter_processor_v2/image_output_gating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Gating for the LLMWhisperer image output mode.

Image output mode produces per-page PNGs that are consumed by the VLM answer
plugin, which ships only with Unstract Cloud. On deployments without the
``plugins.vlm_image_answer`` package the mode is hidden from the adapter's
JSON schema and rejected at save/test time, so users cannot configure a
per-page billed extraction whose output nothing can consume.
"""

import copy
import logging
from typing import Any

from rest_framework.exceptions import ValidationError

from unstract.sdk1.adapters.x2text.constants import ImageOutputConstants

logger = logging.getLogger(__name__)

LLMWHISPERER_ADAPTER_PREFIX = "llmwhisperer|"
IMAGE_OUTPUT_REQUIRES_CLOUD = (
"The 'image' output mode is available only on Unstract Cloud."
)


def _consumer_plugin_available() -> bool:
"""True only when the package AND its backend hooks import.

A half-broken install (package present, ``backend_hooks`` failing to
import) must gate image mode off — fail-closed — rather than leave
the mode enabled while the re-extraction invalidation and deploy
validation hooks quietly stop existing.
"""
try:
import plugins.vlm_image_answer # noqa: F401
except ImportError:
return False
try:
import plugins.vlm_image_answer.backend_hooks # noqa: F401
except ImportError:
logger.error(
"plugins.vlm_image_answer is installed but backend_hooks "
"failed to import; disabling image output mode (fail-closed)"
)
return False
return True


IMAGE_OUTPUT_CONSUMER_AVAILABLE = _consumer_plugin_available()


def _is_image_mode_condition(block: dict[str, Any]) -> bool:
"""True if an ``allOf`` block is conditioned on the image output mode."""
const = (
block.get("if", {})
.get("properties", {})
.get(ImageOutputConstants.OUTPUT_MODE, {})
.get("const")
)
return const == ImageOutputConstants.IMAGE_MODE


def filter_image_output_mode(adapter_id: str, schema: dict[str, Any]) -> dict[str, Any]:
"""Strip the image output-mode option from an adapter's JSON schema.

No-op when the consumer plugin is available, for non-LLMWhisperer
adapters, or when the schema has no image option. Returns a filtered
deep copy otherwise (the SDK-provided schema is shared state).
"""
if IMAGE_OUTPUT_CONSUMER_AVAILABLE:
return schema
if not adapter_id.startswith(LLMWHISPERER_ADAPTER_PREFIX):
return schema
output_mode = schema.get("properties", {}).get(ImageOutputConstants.OUTPUT_MODE, {})
if ImageOutputConstants.IMAGE_MODE not in output_mode.get("enum", []):
return schema

schema = copy.deepcopy(schema)
output_mode = schema["properties"][ImageOutputConstants.OUTPUT_MODE]
idx = output_mode["enum"].index(ImageOutputConstants.IMAGE_MODE)
output_mode["enum"].pop(idx)
enum_names = output_mode.get("enumNames")
if enum_names and len(enum_names) > idx:
enum_names.pop(idx)
if "allOf" in schema:
schema["allOf"] = [
block for block in schema["allOf"] if not _is_image_mode_condition(block)
]
return schema


def validate_image_output_allowed(
adapter_metadata: dict[str, Any] | None, adapter_id: str | None = None
) -> None:
"""Reject image output mode when the consumer plugin is unavailable.

Backstop for the schema filtering above: covers adapters created or
updated via the API (bypassing the UI form) and test-connection calls.
When ``adapter_id`` is unknown (e.g. a metadata-only update) the check
falls back to the metadata alone — only the LLMWhisperer V2 adapter
exposes an ``image`` output mode.
"""
if IMAGE_OUTPUT_CONSUMER_AVAILABLE or not adapter_metadata:
return
if (
adapter_metadata.get(ImageOutputConstants.OUTPUT_MODE)
!= ImageOutputConstants.IMAGE_MODE
):
return
if adapter_id is not None and not adapter_id.startswith(LLMWHISPERER_ADAPTER_PREFIX):
return
logger.warning(
"Rejecting image output mode without the consumer plugin "
"(adapter_id=%s, output_mode=%s)",
adapter_id,
adapter_metadata.get(ImageOutputConstants.OUTPUT_MODE),
)
# Dict detail, not a bare string: this is raised from inside
# ``to_internal_value``, where DRF folds the detail into its per-field
# error mapping — a bare string there crashes error collection with a
# 500 instead of surfacing a clean 400.
raise ValidationError({"adapter_metadata": [IMAGE_OUTPUT_REQUIRES_CLOUD]})
8 changes: 8 additions & 0 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from adapter_processor_v2.adapter_processor import AdapterProcessor
from adapter_processor_v2.constants import AdapterKeys
from adapter_processor_v2.image_output_gating import validate_image_output_allowed
from backend.constants import FieldLengthConstants as FLC
from backend.serializers import AuditSerializer
from unstract.sdk1.constants import AdapterTypes
Expand Down Expand Up @@ -73,6 +74,13 @@ class AdapterInstanceSerializer(BaseAdapterSerializer):

def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]:
if data.get(AdapterKeys.ADAPTER_METADATA, None):
# Reject image output mode on deployments without the cloud
# consumer plugin, before the metadata is encrypted away.
validate_image_output_allowed(
data[AdapterKeys.ADAPTER_METADATA],
data.get(AdapterKeys.ADAPTER_ID)
or getattr(self.instance, "adapter_id", None),
)
encryption_secret: str = settings.ENCRYPTION_KEY
f: Fernet = Fernet(encryption_secret.encode("utf-8"))
json_string: str = json.dumps(data.pop(AdapterKeys.ADAPTER_METADATA))
Expand Down
151 changes: 151 additions & 0 deletions backend/adapter_processor_v2/tests/test_image_output_gating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Tests for the cloud-only gating of the LLMWhisperer image output mode.

The image-mode consumer ships only with Unstract Cloud. Without the
``plugins.vlm_image_answer`` package, the ``image`` output mode must be
hidden from the adapter's JSON schema and rejected at save/test time.
"""

import json
from pathlib import Path

import pytest
from rest_framework.exceptions import ValidationError

from adapter_processor_v2 import image_output_gating as gating
from adapter_processor_v2.image_output_gating import (
IMAGE_OUTPUT_REQUIRES_CLOUD,
filter_image_output_mode,
validate_image_output_allowed,
)

_LLMW_ADAPTER_ID = "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e"

_SCHEMA_PATH = (
Path(__file__).resolve().parents[3]
/ "unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer_v2/src/static"
/ "json_schema.json"
)


def _llmw_schema() -> dict:
return json.loads(_SCHEMA_PATH.read_text())


@pytest.fixture
def consumer_absent(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(gating, "IMAGE_OUTPUT_CONSUMER_AVAILABLE", False)


@pytest.fixture
def consumer_present(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(gating, "IMAGE_OUTPUT_CONSUMER_AVAILABLE", True)


class TestSchemaFiltering:
def test_image_option_stripped_when_consumer_absent(self, consumer_absent) -> None:
schema = _llmw_schema()
filtered = filter_image_output_mode(_LLMW_ADAPTER_ID, schema)

output_mode = filtered["properties"]["output_mode"]
assert "image" not in output_mode["enum"]
# enum / enumNames stay positionally paired
assert len(output_mode["enum"]) == len(output_mode["enumNames"])
assert "Image (PDF only)" not in output_mode["enumNames"]
# The image-conditioned allOf block (conditional description) is gone
assert not any(
block.get("if", {}).get("properties", {}).get("output_mode", {}).get("const")
== "image"
for block in filtered.get("allOf", [])
)
# Unrelated conditional blocks are preserved
assert any("if" in block for block in filtered.get("allOf", []))

def test_source_schema_not_mutated(self, consumer_absent) -> None:
schema = _llmw_schema()
filter_image_output_mode(_LLMW_ADAPTER_ID, schema)
assert "image" in schema["properties"]["output_mode"]["enum"]

def test_schema_untouched_when_consumer_present(self, consumer_present) -> None:
schema = _llmw_schema()
assert filter_image_output_mode(_LLMW_ADAPTER_ID, schema) is schema

def test_non_llmwhisperer_schema_untouched(self, consumer_absent) -> None:
schema = {"properties": {"output_mode": {"enum": ["image"]}}}
assert filter_image_output_mode("someocr|uuid", schema) is schema

def test_schema_without_image_option_untouched(self, consumer_absent) -> None:
schema = {"properties": {"output_mode": {"enum": ["layout_preserving"]}}}
assert filter_image_output_mode(_LLMW_ADAPTER_ID, schema) is schema


class TestSaveTimeValidation:
def test_image_mode_rejected_when_consumer_absent(self, consumer_absent) -> None:
with pytest.raises(ValidationError, match="Unstract Cloud"):
validate_image_output_allowed({"output_mode": "image"}, _LLMW_ADAPTER_ID)

def test_error_message_names_cloud(self, consumer_absent) -> None:
with pytest.raises(ValidationError) as excinfo:
validate_image_output_allowed({"output_mode": "image"}, _LLMW_ADAPTER_ID)
assert IMAGE_OUTPUT_REQUIRES_CLOUD in str(excinfo.value)

def test_image_mode_allowed_when_consumer_present(self, consumer_present) -> None:
validate_image_output_allowed({"output_mode": "image"}, _LLMW_ADAPTER_ID)

def test_other_output_modes_allowed(self, consumer_absent) -> None:
validate_image_output_allowed(
{"output_mode": "layout_preserving"}, _LLMW_ADAPTER_ID
)

def test_non_llmwhisperer_adapter_allowed(self, consumer_absent) -> None:
# Another adapter with a coincidental output_mode key is not gated.
validate_image_output_allowed({"output_mode": "image"}, "someocr|uuid")

def test_unknown_adapter_id_still_rejected(self, consumer_absent) -> None:
# Metadata-only updates lack an adapter id; the metadata alone gates.
with pytest.raises(ValidationError):
validate_image_output_allowed({"output_mode": "image"}, None)

def test_empty_metadata_allowed(self, consumer_absent) -> None:
validate_image_output_allowed(None, _LLMW_ADAPTER_ID)
validate_image_output_allowed({}, _LLMW_ADAPTER_ID)


class TestConsumerProbe:
"""The availability probe must fail closed on a half-broken install."""

def test_absent_package_is_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
# OSS baseline: no plugins.vlm_image_answer package at all. A None
# sys.modules entry forces ImportError regardless of whether the
# local environment has the cloud plugin overlaid.
import sys

monkeypatch.setitem(sys.modules, "plugins.vlm_image_answer", None)
assert gating._consumer_plugin_available() is False

def test_package_without_hooks_is_unavailable(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Package present but backend_hooks unimportable (broken install):
# the mode must gate off rather than stay enabled with dead hooks.
import sys
from types import ModuleType

pkg = ModuleType("plugins.vlm_image_answer")
monkeypatch.setitem(sys.modules, "plugins", ModuleType("plugins"))
monkeypatch.setitem(sys.modules, "plugins.vlm_image_answer", pkg)
monkeypatch.setitem(sys.modules, "plugins.vlm_image_answer.backend_hooks", None)
assert gating._consumer_plugin_available() is False

def test_package_with_hooks_is_available(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
import sys
from types import ModuleType

hooks = ModuleType("plugins.vlm_image_answer.backend_hooks")
pkg = ModuleType("plugins.vlm_image_answer")
pkg.backend_hooks = hooks
monkeypatch.setitem(sys.modules, "plugins", ModuleType("plugins"))
monkeypatch.setitem(sys.modules, "plugins.vlm_image_answer", pkg)
monkeypatch.setitem(sys.modules, "plugins.vlm_image_answer.backend_hooks", hooks)
assert gating._consumer_plugin_available() is True
8 changes: 8 additions & 0 deletions backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from django.core.validators import RegexValidator
from pipeline_v2.models import Pipeline
from prompt_studio.prompt_profile_manager_v2.models import ProfileManager
from prompt_studio.vlm_utils import (
validate_workflow_for_deployment as validate_workflow_vlm_for_deployment,
)
from rest_framework import serializers
from rest_framework.serializers import (
BooleanField,
Expand Down Expand Up @@ -149,6 +152,11 @@ def validate_workflow(self, workflow):
"Destination endpoint must have a connector configured for non-API and non-manual review connections before creating an API deployment."
)

# Image-output-mode profiles need a vision-capable LLM at run time;
# block deployment creation on a definitive mismatch (cloud-only
# check — no-op in OSS, where image mode is gated off entirely).
validate_workflow_vlm_for_deployment(workflow)

return workflow

def validate(self, data):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from backend.serializers import AuditSerializer
from prompt_studio.prompt_profile_manager_v2.constants import ProfileManagerKeys
from prompt_studio.vlm_utils import get_profile_vision_warning

from .models import ProfileManager

Expand Down Expand Up @@ -38,4 +39,9 @@ def to_representation(self, instance): # type: ignore
rep[ProfileManagerKeys.X2TEXT] = AdapterProcessor.get_adapter_instance_by_id(
x2text
)
# Non-blocking image-mode/vision-LLM mismatch warning (cloud-only;
# always None in OSS — key omitted).
vision_warning = get_profile_vision_warning(instance)
if vision_warning:
rep["vision_warning"] = vision_warning
return rep
6 changes: 6 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ class ToolStudioPromptKeys:
VARIABLE_MAP = "variable_map"
RECORD = "record"
FILE_PATH = "file_path"
# Extract-file path that never gets rewritten by summarize-as-source /
# smart-table overrides — the page-image reader keys on this.
EXTRACT_FILE_PATH = "extract_file_path"
# Per-prompt stamp of the x2text adapter's output mode (LLMWhisperer
# only), so the executor detects image mode without a platform call.
X2TEXT_OUTPUT_MODE = "x2text_output_mode"
ENABLE_HIGHLIGHT = "enable_highlight"
ENABLE_WORD_CONFIDENCE = "enable_word_confidence"
REQUIRED = "required"
Expand Down
Loading
Loading