diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index a9c7f40e32..216db7bf45 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -10,8 +10,14 @@ from tenant_account_v2.organization_member_service import OrganizationMemberService from adapter_processor_v2.constants import AdapterKeys, AllowedDomains +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_message, + is_adapter_deprecated, + is_adapter_selectable, +) from adapter_processor_v2.exceptions import ( AdapterNotFound, + DeprecatedAdapter, InternalServiceError, InValidAdapterId, TestAdapterError, @@ -39,6 +45,8 @@ class AdapterProcessor: def get_json_schema(adapter_id: str) -> dict[str, Any]: """Function to return JSON Schema for Adapters.""" schema_details: dict[str, Any] = {} + if is_adapter_deprecated(adapter_id): + raise DeprecatedAdapter(get_deprecation_message(adapter_id)) updated_adapters = AdapterProcessor.__fetch_adapters_by_key_value( AdapterKeys.ID, adapter_id ) @@ -67,6 +75,8 @@ def get_all_supported_adapters(user_email: str, type: str) -> list[dict[Any, Any adapter_id = each_adapter.get(AdapterKeys.ID) if not is_special_user and adapter_id.startswith("noOp"): continue + if is_adapter_deprecated(adapter_id): + continue supported_adapters.append( { @@ -112,7 +122,7 @@ def get_adapter_data_with_key(adapter_id: str, key_value: str) -> Any: @staticmethod def get_icon(adapter: AdapterInstance) -> str: """Registry icon for an adapter, or the warning icon if unresolvable.""" - if not adapter.is_available: + if not adapter.is_available or is_adapter_deprecated(adapter.adapter_id): return AdapterKeys.UNAVAILABLE_ICON try: adapter_class = Adapterkit().get_adapter_class_by_adapter_id( @@ -201,6 +211,14 @@ def __fetch_adapters_by_key_value(key: str, value: Any) -> Adapter: adapters = adapter_kit.get_adapters_list() return [iterate for iterate in adapters if iterate[key] == value] + @staticmethod + def _resolve_selectable_adapter(adapter_pk: str) -> AdapterInstance: + """Adapter for ``adapter_pk``, refusing one that can no longer be chosen.""" + adapter = AdapterInstance.objects.get(pk=adapter_pk) + if not is_adapter_selectable(adapter): + raise DeprecatedAdapter(get_deprecation_message(adapter.adapter_id)) + return adapter + @staticmethod def set_default_triad(default_triad: dict[str, str], user: User) -> None: try: @@ -213,26 +231,30 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None: ) if default_triad.get(AdapterKeys.LLM_DEFAULT, None): - user_default_adapter.default_llm_adapter = AdapterInstance.objects.get( - pk=default_triad[AdapterKeys.LLM_DEFAULT] + user_default_adapter.default_llm_adapter = ( + AdapterProcessor._resolve_selectable_adapter( + default_triad[AdapterKeys.LLM_DEFAULT] + ) ) if default_triad.get(AdapterKeys.EMBEDDING_DEFAULT, None): user_default_adapter.default_embedding_adapter = ( - AdapterInstance.objects.get( - pk=default_triad[AdapterKeys.EMBEDDING_DEFAULT] + AdapterProcessor._resolve_selectable_adapter( + default_triad[AdapterKeys.EMBEDDING_DEFAULT] ) ) if default_triad.get(AdapterKeys.VECTOR_DB_DEFAULT, None): user_default_adapter.default_vector_db_adapter = ( - AdapterInstance.objects.get( - pk=default_triad[AdapterKeys.VECTOR_DB_DEFAULT] + AdapterProcessor._resolve_selectable_adapter( + default_triad[AdapterKeys.VECTOR_DB_DEFAULT] ) ) if default_triad.get(AdapterKeys.X2TEXT_DEFAULT, None): - user_default_adapter.default_x2text_adapter = AdapterInstance.objects.get( - pk=default_triad[AdapterKeys.X2TEXT_DEFAULT] + user_default_adapter.default_x2text_adapter = ( + AdapterProcessor._resolve_selectable_adapter( + default_triad[AdapterKeys.X2TEXT_DEFAULT] + ) ) user_default_adapter.save() @@ -240,7 +262,7 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None: logger.info("Changed defaults successfully") except Exception as e: logger.error(f"Unable to save defaults because: {e}") - if isinstance(e, InValidAdapterId): + if isinstance(e, (InValidAdapterId, DeprecatedAdapter)): raise e else: raise InternalServiceError() diff --git a/backend/adapter_processor_v2/deprecated_adapters.py b/backend/adapter_processor_v2/deprecated_adapters.py new file mode 100644 index 0000000000..acacf93c2c --- /dev/null +++ b/backend/adapter_processor_v2/deprecated_adapters.py @@ -0,0 +1,65 @@ +"""Registry of adapters that are no longer offered. + +Adding an entry here is the whole deprecation: the adapter drops out of the +supported-adapter listing, creation of new instances is rejected, and profiles +can no longer be pointed at it. Existing instances keep rendering so users can +see what to migrate off. + +``platform-service`` gates execution on the ``is_available`` column instead of +this registry (it is a separate service with no access to Django app code), so +a new entry needs a data migration that marks the matching rows unavailable. +""" + +from typing import Any + +# adapter_id ("name|uuid", as stored on AdapterInstance) -> deprecation metadata +DEPRECATED_ADAPTERS: dict[str, dict[str, Any]] = { + "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e": { + "reason": ( + "LLMWhisperer V1 is retired. Please switch to the LLMWhisperer V2 " + "text extractor." + ), + "deprecated_date": "2026-08-31", + "replacement_adapter": "LLMWhisperer V2", + "adapter_name": "LLMWhisperer", + "adapter_type": "X2TEXT", + }, +} + + +def is_adapter_deprecated(adapter_id: str | None) -> bool: + """Whether ``adapter_id`` is a deprecated adapter.""" + return bool(adapter_id) and adapter_id in DEPRECATED_ADAPTERS + + +def get_deprecation_metadata(adapter_id: str | None) -> dict[str, Any] | None: + """Deprecation metadata for ``adapter_id``, or None if it is not deprecated.""" + if not adapter_id: + return None + metadata = DEPRECATED_ADAPTERS.get(adapter_id) + return dict(metadata) if metadata else None + + +def is_adapter_selectable(adapter: Any) -> bool: + """Whether an ``AdapterInstance`` may back a new profile, default or config. + + Covers the three ways an adapter stops being a valid choice: usage + exhausted (``is_usable``), withdrawn from the SDK (``is_available``), and + deprecated here. Existing selections are not re-validated against this — + they stay readable so users can see what to migrate off. + """ + return bool( + adapter is not None + and adapter.is_usable + and adapter.is_available + and not is_adapter_deprecated(adapter.adapter_id) + ) + + +def get_deprecation_message(adapter_id: str | None) -> str: + """User-facing reason ``adapter_id`` can no longer be used.""" + metadata = get_deprecation_metadata(adapter_id) + if not metadata: + return "This adapter has been deprecated and can no longer be used." + name = metadata.get("adapter_name") or "This adapter" + return f"{name} has been deprecated. {metadata['reason']}" diff --git a/backend/adapter_processor_v2/exceptions.py b/backend/adapter_processor_v2/exceptions.py index 7aa2e586f4..05faa9a5fe 100644 --- a/backend/adapter_processor_v2/exceptions.py +++ b/backend/adapter_processor_v2/exceptions.py @@ -23,6 +23,11 @@ class InValidAdapterId(APIException): default_detail = "Adapter ID is not Valid." +class DeprecatedAdapter(APIException): + status_code = 400 + default_detail = "This adapter has been deprecated and can no longer be used." + + class InternalServiceError(APIException): status_code = 500 default_detail = "Internal Service error" diff --git a/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py b/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py new file mode 100644 index 0000000000..be4b52c7fc --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.30 on 2026-08-31 06:48 + +import logging + +from django.db import migrations + +logger = logging.getLogger(__name__) + +ADAPTER_ID = "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e" + +# Frozen copy of the adapter_processor_v2.deprecated_adapters entry. Runtime +# behaviour reads the registry, so wording drifting from it changes nothing. +DEPRECATION_METADATA = { + "reason": ( + "LLMWhisperer V1 is retired. Please switch to the LLMWhisperer V2 " + "text extractor." + ), + "deprecated_date": "2026-08-31", + "replacement_adapter": "LLMWhisperer V2", + "adapter_name": "LLMWhisperer", + "adapter_type": "X2TEXT", +} + + +def mark_llmwhisperer_v1_deprecated(apps, schema_editor): + """Mark every LLMWhisperer V1 instance unavailable, across all orgs. + + platform-service reads this column to reject execution, so a row missed + here would fail deep in the SDK instead of with the deprecation message. + """ + AdapterInstance = apps.get_model( # NOSONAR + "adapter_processor_v2", "AdapterInstance" + ) + + updated = AdapterInstance.objects.filter(adapter_id=ADAPTER_ID).update( + is_available=False, deprecation_metadata=DEPRECATION_METADATA + ) + logger.info("Marked %s LLMWhisperer V1 adapter instance(s) as deprecated.", updated) + + +def reverse_deprecation(apps, schema_editor): + AdapterInstance = apps.get_model( # NOSONAR + "adapter_processor_v2", "AdapterInstance" + ) + + updated = AdapterInstance.objects.filter(adapter_id=ADAPTER_ID).update( + is_available=True, deprecation_metadata=None + ) + logger.info("Reversed deprecation for %s LLMWhisperer V1 instance(s).", updated) + + +class Migration(migrations.Migration): + dependencies = [ + ("adapter_processor_v2", "0006_adapterinstance_adapter_org_modified_idx"), + ] + + operations = [ + migrations.RunPython(mark_llmwhisperer_v1_deprecated, reverse_deprecation), + ] diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 8209dd75c7..ad5b4771a5 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -5,7 +5,7 @@ from cryptography.fernet import Fernet from django.conf import settings from rest_framework import serializers -from rest_framework.serializers import ModelSerializer +from rest_framework.serializers import ModelSerializer, ValidationError from tenant_account_v2.sharing_helpers import ( serialize_group_refs, serialize_owner_refs, @@ -14,6 +14,11 @@ from adapter_processor_v2.adapter_processor import AdapterProcessor from adapter_processor_v2.constants import AdapterKeys +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_message, + get_deprecation_metadata, + is_adapter_deprecated, +) from backend.constants import FieldLengthConstants as FLC from backend.serializers import AuditSerializer from unstract.sdk1.constants import AdapterTypes @@ -28,6 +33,26 @@ class TestAdapterSerializer(serializers.Serializer): adapter_type = serializers.JSONField() +def _add_deprecation_info(rep: dict[str, Any], instance: AdapterInstance) -> bool: + """Stamp availability keys onto ``rep``; returns whether the adapter is usable. + + The registry is consulted alongside the stored flag so a newly deprecated + adapter reads as deprecated before its backfill migration has run. + """ + is_available = instance.is_available and not is_adapter_deprecated( + instance.adapter_id + ) + rep[AdapterKeys.IS_AVAILABLE] = is_available + rep[AdapterKeys.IS_DEPRECATED] = not is_available + if not is_available: + metadata = ( + get_deprecation_metadata(instance.adapter_id) or instance.deprecation_metadata + ) + if metadata: + rep[AdapterKeys.DEPRECATION_METADATA] = metadata + return is_available + + class BaseAdapterSerializer(AuditSerializer): # ``shared_groups`` is no longer an M2M on AdapterInstance — declare it # explicitly so ``fields = "__all__"`` continues to expose it. Share @@ -71,6 +96,19 @@ class AdapterInstanceSerializer(BaseAdapterSerializer): Used for CRUD other than listing """ + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + """Reject a deprecated adapter_id. + + Sits on the serializer rather than the create view because + ``adapter_id`` is writable, so update/partial_update reach it too. + """ + adapter_id = attrs.get(AdapterKeys.ADAPTER_ID) + if is_adapter_deprecated(adapter_id): + raise ValidationError( + {AdapterKeys.ADAPTER_ID: get_deprecation_message(adapter_id)} + ) + return attrs + def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]: if data.get(AdapterKeys.ADAPTER_METADATA, None): encryption_secret: str = settings.ENCRYPTION_KEY @@ -99,15 +137,11 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: rep[AdapterKeys.ADAPTER_METADATA] = adapter_metadata - # Add deprecation information - rep[AdapterKeys.IS_AVAILABLE] = instance.is_available - rep[AdapterKeys.IS_DEPRECATED] = not instance.is_available - if not instance.is_available and instance.deprecation_metadata: - rep[AdapterKeys.DEPRECATION_METADATA] = instance.deprecation_metadata + is_available = _add_deprecation_info(rep, instance) # Only retrieve context window and icon for available adapters # Avoid SDK calls for deprecated adapters - if instance.is_available: + if is_available: # Retrieve context window if adapter is a LLM # For other adapter types, context_window is not relevant. if instance.adapter_type == AdapterTypes.LLM.value: @@ -178,11 +212,7 @@ class Meta(BaseAdapterSerializer.Meta): def to_representation(self, instance: AdapterInstance) -> dict[str, str]: rep: dict[str, str] = super().to_representation(instance) - # Add deprecation information - rep[AdapterKeys.IS_AVAILABLE] = instance.is_available - rep[AdapterKeys.IS_DEPRECATED] = not instance.is_available - if not instance.is_available and instance.deprecation_metadata: - rep[AdapterKeys.DEPRECATION_METADATA] = instance.deprecation_metadata + _add_deprecation_info(rep, instance) rep[common.ICON] = AdapterProcessor.get_icon(instance) diff --git a/backend/adapter_processor_v2/tests/test_deprecated_adapters.py b/backend/adapter_processor_v2/tests/test_deprecated_adapters.py new file mode 100644 index 0000000000..b87925c759 --- /dev/null +++ b/backend/adapter_processor_v2/tests/test_deprecated_adapters.py @@ -0,0 +1,111 @@ +"""Guards for the adapter deprecation registry (UN-2896). + +Every entry in ``DEPRECATED_ADAPTERS`` must be absent from the SDK registry and +absent from the supported-adapter listing, so a deprecated adapter cannot be +re-registered or offered for creation without this failing. +""" + +from __future__ import annotations + +import pytest +from rest_framework.serializers import ValidationError + +from adapter_processor_v2.adapter_processor import AdapterProcessor +from adapter_processor_v2.deprecated_adapters import ( + DEPRECATED_ADAPTERS, + get_deprecation_message, + is_adapter_deprecated, + is_adapter_selectable, +) +from adapter_processor_v2.exceptions import DeprecatedAdapter +from unstract.sdk1.adapters.adapterkit import Adapterkit + +LLM_WHISPERER_V1 = "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e" + +REQUIRED_METADATA_KEYS = {"reason", "deprecated_date", "adapter_name", "adapter_type"} + + +def test_llm_whisperer_v1_is_registered_as_deprecated(): + assert is_adapter_deprecated(LLM_WHISPERER_V1) + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_deprecated_adapter_is_not_in_sdk_registry(adapter_id): + """A deprecated adapter must not be registered in the SDK.""" + assert adapter_id not in Adapterkit().adapters + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_deprecated_adapter_metadata_is_complete(adapter_id): + assert REQUIRED_METADATA_KEYS <= set(DEPRECATED_ADAPTERS[adapter_id]) + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_deprecated_adapter_is_not_offered_for_creation(adapter_id): + adapter_type = DEPRECATED_ADAPTERS[adapter_id]["adapter_type"] + offered = AdapterProcessor.get_all_supported_adapters( + user_email="someone@example.com", type=adapter_type + ) + assert adapter_id not in {adapter["id"] for adapter in offered} + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_json_schema_is_refused_for_deprecated_adapter(adapter_id): + with pytest.raises(DeprecatedAdapter): + AdapterProcessor.get_json_schema(adapter_id) + + +def test_deprecation_message_names_the_replacement(): + message = get_deprecation_message(LLM_WHISPERER_V1) + assert "LLMWhisperer" in message + assert "V2" in message + + +def test_unknown_adapter_is_not_deprecated(): + assert not is_adapter_deprecated("openai|some-uuid") + assert not is_adapter_deprecated(None) + + +class _FakeAdapter: + """Stand-in for AdapterInstance; is_adapter_selectable reads 4 fields.""" + + def __init__(self, adapter_id, is_usable=True, is_available=True): + self.adapter_id = adapter_id + self.is_usable = is_usable + self.is_available = is_available + + +def test_selectable_adapter_passes(): + assert is_adapter_selectable(_FakeAdapter("openai|some-uuid")) + + +@pytest.mark.parametrize( + "adapter", + [ + None, + _FakeAdapter(LLM_WHISPERER_V1), + _FakeAdapter("openai|some-uuid", is_usable=False), + _FakeAdapter("openai|some-uuid", is_available=False), + ], + ids=["none", "deprecated", "usage-exhausted", "withdrawn-from-sdk"], +) +def test_unselectable_adapters_are_refused(adapter): + """Guards default-profile creation and set_default_triad.""" + assert not is_adapter_selectable(adapter) + + +def test_serializer_rejects_deprecated_adapter_id(): + """Covers create AND update/partial_update, since adapter_id is writable.""" + from adapter_processor_v2.serializers import AdapterInstanceSerializer + + serializer = AdapterInstanceSerializer() + with pytest.raises(ValidationError) as exc: + serializer.validate({"adapter_id": LLM_WHISPERER_V1}) + assert "adapter_id" in exc.value.detail + + +def test_serializer_allows_supported_adapter_id(): + from adapter_processor_v2.serializers import AdapterInstanceSerializer + + attrs = {"adapter_id": "llmwhisperer|a5e6b8af-3e1f-4a80-b006-d017e8e67f93"} + assert AdapterInstanceSerializer().validate(attrs) == attrs diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index f2aef82b0c..8bbe003fcf 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -33,9 +33,14 @@ from adapter_processor_v2.adapter_processor import AdapterProcessor from adapter_processor_v2.constants import AdapterKeys +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_message, + is_adapter_deprecated, +) from adapter_processor_v2.exceptions import ( CannotDeleteDefaultAdapter, DeleteAdapterInUseError, + DeprecatedAdapter, DuplicateAdapterNameError, IdIsMandatory, InValidType, @@ -130,6 +135,8 @@ def test(self, request: Request) -> Response: serializer: AdapterInstanceSerializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) adapter_id = serializer.validated_data.get(AdapterKeys.ADAPTER_ID) + if is_adapter_deprecated(adapter_id): + raise DeprecatedAdapter(get_deprecation_message(adapter_id)) adapter_metadata = serializer.validated_data.get(AdapterKeys.ADAPTER_METADATA) adapter_metadata[AdapterKeys.ADAPTER_TYPE] = serializer.validated_data.get( AdapterKeys.ADAPTER_TYPE @@ -246,6 +253,8 @@ def create(self, request: Any) -> Response: ): use_platform_unstract_key = True + # Deprecated adapter_ids are rejected in AdapterInstanceSerializer.validate, + # which also covers update/partial_update. serializer.is_valid(raise_exception=True) adapter_type = serializer.validated_data.get(AdapterKeys.ADAPTER_TYPE) self._enforce_llm_creation_restriction(request, adapter_type) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py index 5fa6d3bf93..7b3825fec4 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py @@ -2,6 +2,10 @@ from typing import Any from adapter_processor_v2.adapter_processor import AdapterProcessor +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_message, + is_adapter_deprecated, +) from adapter_processor_v2.models import AdapterInstance from rest_framework.serializers import ValidationError @@ -29,10 +33,11 @@ class Meta: validators = [] def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: - """Reject a change to an adapter the requester cannot access. + """Reject a change to an adapter the requester cannot access or use. An unchanged value passes, so a co-owner can still save a profile - that points at an adapter shared only with the owner. + that points at an adapter shared only with the owner, and a profile + already on a deprecated adapter stays editable in its other fields. """ request = self.context.get("request") if not request: @@ -44,6 +49,10 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: continue if not accessible.filter(id=adapter.id).exists(): raise ValidationError({field: "No access to the selected adapter."}) + if not adapter.is_available or is_adapter_deprecated(adapter.adapter_id): + raise ValidationError( + {field: get_deprecation_message(adapter.adapter_id)} + ) return attrs def to_representation(self, instance): # type: ignore diff --git a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py index ba157801ce..8e73f65e99 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py +++ b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py @@ -8,6 +8,7 @@ from account_v2.constants import Common from account_v2.models import User +from adapter_processor_v2.deprecated_adapters import is_adapter_selectable from adapter_processor_v2.models import AdapterInstance, UserDefaultAdapter from django.conf import settings from django.db import transaction @@ -143,8 +144,8 @@ def create_default_profile_manager(user: User, tool_id: uuid) -> None: "vector_store": default_adapter.default_vector_db_adapter, "x2text": default_adapter.default_x2text_adapter, } - # A valid profile needs a usable default for every adapter type - if not all(adapter and adapter.is_usable for adapter in adapters.values()): + # A valid profile needs a selectable default for every adapter type + if not all(is_adapter_selectable(adapter) for adapter in adapters.values()): logger.info( "Skipping default profile creation: " "incomplete or unusable default adapters" @@ -3137,7 +3138,7 @@ def validate_adapter_configuration( ] for adapter in adapters_to_check: - if not adapter or not adapter.is_usable: + if not is_adapter_selectable(adapter): warning_message = ( "Some adapters may need to be configured before you can use " "this project. Please check the profile settings." diff --git a/backend/sample.env b/backend/sample.env index 700422d1a4..0ad5903053 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -150,15 +150,6 @@ SYSTEM_ADMIN_EMAIL="admin@abc.com" # Set Django Session Expiry Time (in seconds) SESSION_COOKIE_AGE=86400 -# Control async extraction of LLMWhisperer -# Time in seconds to wait before polling LLMWhisperer's status API -ADAPTER_LLMW_POLL_INTERVAL=30 -# Total number of times to poll the status API. -# 500 mins to allow 1500 (max pages limit) * 20 (approx time in sec to process a page) -ADAPTER_LLMW_MAX_POLLS=1000 -# Number of times to retry the /whisper-status API before failing the extraction -ADAPTER_LLMW_STATUS_RETRIES=5 - # Enable logging of workflow history. ENABLE_LOG_HISTORY=True # Interval in seconds for periodic consumer operations. diff --git a/frontend/public/icons/adapter-icons/LLMWhisperer.png b/frontend/public/icons/adapter-icons/LLMWhisperer.png deleted file mode 100644 index 417301d468..0000000000 Binary files a/frontend/public/icons/adapter-icons/LLMWhisperer.png and /dev/null differ diff --git a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx index fd6bd59435..e53eab085a 100644 --- a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx +++ b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx @@ -9,6 +9,7 @@ import { Text, Title } from "@/components/ui/shims/antd-typography"; import "./AdapterSelectionModal.css"; +import { usableAdapters } from "../../../helpers/GetStaticData"; import { fetchAllPages } from "../../../helpers/pagination"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; @@ -63,7 +64,12 @@ function AdapterSelectionModal({ const [llm, embedding, vectorDb, x2text] = await Promise.all(requests); - setAdapters({ llm, embedding, vectorDb, x2text }); + setAdapters({ + llm: usableAdapters(llm), + embedding: usableAdapters(embedding), + vectorDb: usableAdapters(vectorDb), + x2text: usableAdapters(x2text), + }); } catch (err) { setAlertDetails( handleException(err, "Failed to fetch available adapters"), diff --git a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx index 8d845ae822..9f9aea5cb1 100644 --- a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx +++ b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx @@ -9,7 +9,10 @@ import { Collapse } from "@/components/ui/shims/antd-overlays"; import { Typography } from "@/components/ui/shims/antd-typography"; import { cn } from "@/lib/utils"; -import { getBackendErrorDetail } from "../../../helpers/GetStaticData"; +import { + getBackendErrorDetail, + usableAdapters, +} from "../../../helpers/GetStaticData"; import { fetchAllPages } from "../../../helpers/pagination"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; @@ -191,7 +194,7 @@ function AddLlmProfile({ const embedding = []; const x2Text = []; - data.forEach((item) => { + usableAdapters(data).forEach((item) => { const option = { value: item?.id, label: item?.adapter_name }; if (item?.adapter_type === "LLM") { llm.push(option); diff --git a/frontend/src/components/settings/default-triad/DefaultTriad.jsx b/frontend/src/components/settings/default-triad/DefaultTriad.jsx index d7d1a56754..b16f366fcc 100644 --- a/frontend/src/components/settings/default-triad/DefaultTriad.jsx +++ b/frontend/src/components/settings/default-triad/DefaultTriad.jsx @@ -205,8 +205,14 @@ function DefaultTriad() { {dropdownData .filter((data) => data?.adapter_type === type) .map((data) => ( - ))} diff --git a/frontend/src/helpers/GetStaticData.js b/frontend/src/helpers/GetStaticData.js index 9e067896c0..22d04e28dd 100644 --- a/frontend/src/helpers/GetStaticData.js +++ b/frontend/src/helpers/GetStaticData.js @@ -380,6 +380,11 @@ const displayPromptResult = ( return String(parsedData); }; +// Deprecated adapters stay listed so users can see what to migrate off, but +// they cannot be selected or counted as configured. +const usableAdapters = (adapters) => + (adapters || []).filter((adapter) => !adapter?.is_deprecated); + const onboardCompleted = (adaptersList) => { if (!Array.isArray(adaptersList)) { return false; @@ -808,6 +813,7 @@ export { titleCase, toolIdeOutput, UNSTRACT_ADMIN, + usableAdapters, wfExecutionTypes, workflowStatus, }; diff --git a/frontend/src/hooks/useSessionValid.js b/frontend/src/hooks/useSessionValid.js index a2c2c0e092..2b890cf5e1 100644 --- a/frontend/src/hooks/useSessionValid.js +++ b/frontend/src/hooks/useSessionValid.js @@ -3,6 +3,7 @@ import Cookies from "js-cookie"; import { useNavigate } from "react-router-dom"; import { listFlags } from "../helpers/FeatureFlagsData.js"; import { getSessionData } from "../helpers/GetSessionData"; +import { usableAdapters } from "../helpers/GetStaticData"; import { useExceptionHandler } from "../hooks/useExceptionHandler.jsx"; import { useAlertStore } from "../store/alert-store"; import { useSessionStore } from "../store/session-store"; @@ -148,7 +149,7 @@ function useSessionValid() { const getAdapterDetails = await axios(requestOptions); const adapterTypes = [ ...new Set( - getAdapterDetails?.data?.map((obj) => + usableAdapters(getAdapterDetails?.data).map((obj) => obj.adapter_type.toLowerCase(), ), ), diff --git a/platform-service/src/unstract/platform_service/controller/platform.py b/platform-service/src/unstract/platform_service/controller/platform.py index fbc8cdd8bb..bf7bc61e97 100644 --- a/platform-service/src/unstract/platform_service/controller/platform.py +++ b/platform-service/src/unstract/platform_service/controller/platform.py @@ -390,6 +390,11 @@ def adapter_instance() -> Any: f"{adapter_instance_id}, Error: {msg}" ) raise APIError(message=msg, code=403) + except APIError: + # Already carries its own status and user-facing message (adapter not + # found, adapter deprecated); re-wrapping would report it as a 500 and + # log a traceback for what is a client-side condition. + raise except Exception as e: msg = f"Error while getting db adapter settings for {adapter_instance_id}: {e}" raise APIError(message=msg) diff --git a/platform-service/src/unstract/platform_service/helper/adapter_instance.py b/platform-service/src/unstract/platform_service/helper/adapter_instance.py index 270bfc1d77..f027845c51 100644 --- a/platform-service/src/unstract/platform_service/helper/adapter_instance.py +++ b/platform-service/src/unstract/platform_service/helper/adapter_instance.py @@ -25,7 +25,8 @@ def get_adapter_instance_from_db( _type_: _description_ """ query = ( - "SELECT id, adapter_id, adapter_name, adapter_type, adapter_metadata_b" + "SELECT id, adapter_id, adapter_name, adapter_type, adapter_metadata_b," + " is_available" f' FROM "{DB_SCHEMA}".{DBTable.ADAPTER_INSTANCE} x ' f"WHERE id=%s and organization_id=%s" ) @@ -37,4 +38,15 @@ def get_adapter_instance_from_db( ) columns = [desc[0] for desc in cursor.description] data_dict: dict[str, Any] = dict(zip(columns, result_row, strict=False)) + # Deprecated adapters are no longer in the SDK registry, so resolving + # one would fail with an unrelated error further down the call. + if not data_dict.pop("is_available", True): + adapter_name = data_dict.get("adapter_name") or adapter_instance_id + raise APIError( + message=( + f"Adapter '{adapter_name}' has been deprecated and can no " + "longer be used. Please reconfigure with a supported adapter." + ), + code=400, + ) return data_dict diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md deleted file mode 100644 index 0c1a9ea131..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Unstract LLMWhisperer X2Text Adapter - -## Env variables - -The below env variables are resolved by LLMWhisperer adapter - -| Variable | Description | -| ---------------------------- | -------------------------------------------------------------------------------------------- | -| `ADAPTER_LLMW_POLL_INTERVAL` | Time in seconds to wait before polling LLMWhisperer's status API. Defaults to 30s | -| `ADAPTER_LLMW_MAX_POLLS` | Total number of times to poll the status API. Defaults to 30 | diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml deleted file mode 100644 index 4ab6656650..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "unstract-llm_whisperer-x2text" -version = "0.0.1" -description = "LLMWhisperer X2Text Adapter" -authors = [{ name = "Zipstack Inc.", email = "devsupport@zipstack.com" }] -dependencies = [] -requires-python = ">=3.9" -readme = "README.md" -classifiers = ["Programming Language :: Python"] -license = { text = "MIT" } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src"] -# source-includes = ["tests"] diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py deleted file mode 100644 index ba216498fa..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .llm_whisperer import LLMWhisperer - -metadata = { - "name": LLMWhisperer.__name__, - "version": "1.0.0", - "adapter": LLMWhisperer, - "description": "LLMWhisperer X2Text adapter", - "is_active": True, -} diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py deleted file mode 100644 index 6b11d65bbd..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py +++ /dev/null @@ -1,106 +0,0 @@ -import os -from enum import Enum - - -class ProcessingModes(Enum): - OCR = "ocr" - TEXT = "text" - - -class Modes(Enum): - NATIVE_TEXT = "native_text" - LOW_COST = "low_cost" - HIGH_QUALITY = "high_quality" - FORM = "form" - - -class OutputModes(Enum): - LINE_PRINTER = "line-printer" - DUMP_TEXT = "dump-text" - TEXT = "text" - - -class HTTPMethod(Enum): - GET = "GET" - POST = "POST" - - -class WhispererHeader: - UNSTRACT_KEY = "unstract-key" - - -class WhispererEndpoint: - """Endpoints available at LLMWhisperer service.""" - - TEST_CONNECTION = "test-connection" - WHISPER = "whisper" - STATUS = "whisper-status" - RETRIEVE = "whisper-retrieve" - - -class WhispererEnv: - """Env variables for LLMWhisperer. - - Can be used to alter behaviour at runtime. - - Attributes: - POLL_INTERVAL: Time in seconds to wait before polling - LLMWhisperer's status API. Defaults to 30s - MAX_POLLS: Total number of times to poll the status API. - Set to -1 to poll indefinitely. Defaults to -1 - """ - - POLL_INTERVAL = "ADAPTER_LLMW_POLL_INTERVAL" - MAX_POLLS = "ADAPTER_LLMW_MAX_POLLS" - - -class WhispererConfig: - """Dictionary keys used to configure LLMWhisperer service.""" - - URL = "url" - PROCESSING_MODE = "processing_mode" - MODE = "mode" - OUTPUT_MODE = "output_mode" - UNSTRACT_KEY = "unstract_key" - MEDIAN_FILTER_SIZE = "median_filter_size" - GAUSSIAN_BLUR_RADIUS = "gaussian_blur_radius" - FORCE_TEXT_PROCESSING = "force_text_processing" - LINE_SPLITTER_TOLERANCE = "line_splitter_tolerance" - HORIZONTAL_STRETCH_FACTOR = "horizontal_stretch_factor" - PAGES_TO_EXTRACT = "pages_to_extract" - STORE_METADATA_FOR_HIGHLIGHTING = "store_metadata_for_highlighting" - ADD_LINE_NOS = "add_line_nos" - OUTPUT_JSON = "output_json" - PAGE_SEPARATOR = "page_seperator" - MARK_VERTICAL_LINES = "mark_vertical_lines" - MARK_HORIZONTAL_LINES = "mark_horizontal_lines" - - -class WhisperStatus: - """Values returned / used by /whisper-status endpoint.""" - - PROCESSING = "processing" - PROCESSED = "processed" - DELIVERED = "delivered" - UNKNOWN = "unknown" - # Used for async processing - WHISPER_HASH = "whisper-hash" - STATUS = "status" - - -class WhispererDefaults: - """Defaults meant for LLMWhisperer.""" - - MEDIAN_FILTER_SIZE = 0 - GAUSSIAN_BLUR_RADIUS = 0.0 - FORCE_TEXT_PROCESSING = False - LINE_SPLITTER_TOLERANCE = 0.75 - HORIZONTAL_STRETCH_FACTOR = 1.0 - POLL_INTERVAL = int(os.getenv(WhispererEnv.POLL_INTERVAL, 30)) - MAX_POLLS = int(os.getenv(WhispererEnv.MAX_POLLS, 30)) - PAGES_TO_EXTRACT = "" - ADD_LINE_NOS = True - OUTPUT_JSON = True - PAGE_SEPARATOR = "<<< >>>" - MARK_VERTICAL_LINES = False - MARK_HORIZONTAL_LINES = False diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py deleted file mode 100644 index 1d8f1ee388..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py +++ /dev/null @@ -1,451 +0,0 @@ -import json -import logging -import os -import time -from pathlib import Path -from typing import Any - -import requests -from requests import Response -from requests.exceptions import ConnectionError, HTTPError, Timeout -from unstract.sdk1.adapters.exceptions import ExtractorError -from unstract.sdk1.adapters.utils import AdapterUtils -from unstract.sdk1.adapters.x2text.constants import X2TextConstants -from unstract.sdk1.adapters.x2text.dto import ( - TextExtractionMetadata, - TextExtractionResult, -) -from unstract.sdk1.adapters.x2text.llm_whisperer.src.constants import ( - HTTPMethod, - OutputModes, - ProcessingModes, - WhispererConfig, - WhispererDefaults, - WhispererEndpoint, - WhispererHeader, - WhisperStatus, -) -from unstract.sdk1.adapters.x2text.x2text_adapter import X2TextAdapter -from unstract.sdk1.constants import MimeType -from unstract.sdk1.file_storage import FileStorage, FileStorageProvider - -logger = logging.getLogger(__name__) - - -class LLMWhisperer(X2TextAdapter): - def __init__(self, settings: dict[str, Any]) -> None: - """Initialize the LLMWhisperer text extraction adapter. - - Args: - settings: Configuration dictionary containing LLMWhisperer API settings - including API key, base URL, and other parameters. - """ - super().__init__("LLMWhisperer") - self.config = settings - - SCHEMA_PATH = f"{os.path.dirname(__file__)}/static/json_schema.json" - - @staticmethod - def get_id() -> str: - return "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e" - - @staticmethod - def get_name() -> str: - return "LLMWhisperer" - - @staticmethod - def get_description() -> str: - return "LLMWhisperer X2Text" - - @staticmethod - def get_icon() -> str: - return "/icons/adapter-icons/LLMWhisperer.png" - - def _get_request_headers(self) -> dict[str, Any]: - """Obtains the request headers to authenticate with LLMWhisperer. - - Returns: - str: Request headers - """ - return { - "accept": MimeType.JSON, - WhispererHeader.UNSTRACT_KEY: self.config.get(WhispererConfig.UNSTRACT_KEY), - } - - def _make_request( - self, - request_method: HTTPMethod, - request_endpoint: str, - headers: dict[str, object] | None = None, - params: dict[str, object] | None = None, - data: object | None = None, - ) -> Response: - """Makes a request to LLMWhisperer service. - - Args: - request_method (HTTPMethod): HTTPMethod to call. Can be GET or POST - request_endpoint (str): LLMWhisperer endpoint to hit - headers (Optional[dict[str, Any]], optional): Headers to pass. - Defaults to None. - params (Optional[dict[str, Any]], optional): Query params to pass. - Defaults to None. - data (Optional[Any], optional): Data to pass in case of POST. - Defaults to None. - - Returns: - Response: Response from the request - """ - llm_whisperer_svc_url = ( - f"{self.config.get(WhispererConfig.URL)}/v1/{request_endpoint}" - ) - if not headers: - headers = self._get_request_headers() - - try: - response: Response - if request_method == HTTPMethod.GET: - response = requests.get( - url=llm_whisperer_svc_url, headers=headers, params=params - ) - elif request_method == HTTPMethod.POST: - response = requests.post( - url=llm_whisperer_svc_url, - headers=headers, - params=params, - data=data, - ) - else: - raise ExtractorError(f"Unsupported request method: {request_method}") - response.raise_for_status() - except ConnectionError as e: - logger.error(f"Adapter error: {e}") - raise ExtractorError( - "Unable to connect to LLMWhisperer service, please check the URL" - ) from e - except Timeout as e: - msg = "Request to LLMWhisperer has timed out" - logger.error(f"{msg}: {e}") - raise ExtractorError(msg) from e - except HTTPError as e: - logger.error(f"Adapter error: {e}") - default_err = "Error while calling the LLMWhisperer service" - msg = AdapterUtils.get_msg_from_request_exc( - err=e, message_key="message", default_err=default_err - ) - raise ExtractorError(msg) from e - return response - - def _get_whisper_params(self, enable_highlight: bool = False) -> dict[str, Any]: - """Gets query params meant for /whisper endpoint. - - The params is filled based on the configuration passed. - - Returns: - dict[str, Any]: Query params - """ - params = { - WhispererConfig.PROCESSING_MODE: self.config.get( - WhispererConfig.PROCESSING_MODE, ProcessingModes.TEXT.value - ), - # Not providing default value to maintain legacy compatablity - # Providing default value will overide the params - # processing_mode, force_text_processing - WhispererConfig.MODE: self.config.get(WhispererConfig.MODE), - WhispererConfig.OUTPUT_MODE: self.config.get( - WhispererConfig.OUTPUT_MODE, OutputModes.LINE_PRINTER.value - ), - WhispererConfig.FORCE_TEXT_PROCESSING: self.config.get( - WhispererConfig.FORCE_TEXT_PROCESSING, - WhispererDefaults.FORCE_TEXT_PROCESSING, - ), - WhispererConfig.LINE_SPLITTER_TOLERANCE: self.config.get( - WhispererConfig.LINE_SPLITTER_TOLERANCE, - WhispererDefaults.LINE_SPLITTER_TOLERANCE, - ), - WhispererConfig.HORIZONTAL_STRETCH_FACTOR: self.config.get( - WhispererConfig.HORIZONTAL_STRETCH_FACTOR, - WhispererDefaults.HORIZONTAL_STRETCH_FACTOR, - ), - WhispererConfig.PAGES_TO_EXTRACT: self.config.get( - WhispererConfig.PAGES_TO_EXTRACT, - WhispererDefaults.PAGES_TO_EXTRACT, - ), - WhispererConfig.ADD_LINE_NOS: WhispererDefaults.ADD_LINE_NOS, - WhispererConfig.OUTPUT_JSON: WhispererDefaults.OUTPUT_JSON, - WhispererConfig.PAGE_SEPARATOR: self.config.get( - WhispererConfig.PAGE_SEPARATOR, - WhispererDefaults.PAGE_SEPARATOR, - ), - WhispererConfig.MARK_VERTICAL_LINES: self.config.get( - WhispererConfig.MARK_VERTICAL_LINES, - WhispererDefaults.MARK_VERTICAL_LINES, - ), - WhispererConfig.MARK_HORIZONTAL_LINES: self.config.get( - WhispererConfig.MARK_HORIZONTAL_LINES, - WhispererDefaults.MARK_HORIZONTAL_LINES, - ), - } - if not params[WhispererConfig.FORCE_TEXT_PROCESSING]: - params.update( - { - WhispererConfig.MEDIAN_FILTER_SIZE: self.config.get( - WhispererConfig.MEDIAN_FILTER_SIZE, - WhispererDefaults.MEDIAN_FILTER_SIZE, - ), - WhispererConfig.GAUSSIAN_BLUR_RADIUS: self.config.get( - WhispererConfig.GAUSSIAN_BLUR_RADIUS, - WhispererDefaults.GAUSSIAN_BLUR_RADIUS, - ), - } - ) - - if enable_highlight: - params.update( - {WhispererConfig.STORE_METADATA_FOR_HIGHLIGHTING: enable_highlight} - ) - return params - - def test_connection(self) -> bool: - self._make_request( - request_method=HTTPMethod.GET, - request_endpoint=WhispererEndpoint.TEST_CONNECTION, - ) - return True - - def _check_status_until_ready( - self, whisper_hash: str, headers: dict[str, Any], params: dict[str, Any] - ) -> WhisperStatus: - """Checks the extraction status by polling. - - Polls the /whisper-status endpoint in fixed intervals of - env: ADAPTER_LLMW_POLL_INTERVAL for a certain number of times - controlled by env: ADAPTER_LLMW_MAX_POLLS. - - Args: - whisper_hash (str): Identifier for the extraction, - returned by LLMWhisperer - headers (dict[str, Any]): Headers to pass for the status check - params (dict[str, Any]): Params to pass for the status check - - Returns: - WhisperStatus: Status of the extraction - """ - poll_interval = WhispererDefaults.POLL_INTERVAL - max_polls = WhispererDefaults.MAX_POLLS - request_count = 0 - - # Check status in fixed intervals upto max poll count. - while True: - request_count += 1 - logger.info( - f"Checking status with interval: {poll_interval}s" - f", request count: {request_count} [max: {max_polls}]" - ) - status_response = self._make_request( - request_method=HTTPMethod.GET, - request_endpoint=WhispererEndpoint.STATUS, - headers=headers, - params=params, - ) - if status_response.status_code == 200: - status_data = status_response.json() - status = status_data.get(WhisperStatus.STATUS, WhisperStatus.UNKNOWN) - logger.info(f"Whisper status for {whisper_hash}: {status}") - if status in [WhisperStatus.PROCESSED, WhisperStatus.DELIVERED]: - break - else: - raise ExtractorError( - "Error checking LLMWhisperer status: " - f"{status_response.status_code} - {status_response.text}" - ) - - # Exit with error if max poll count is reached - if request_count >= max_polls: - raise ExtractorError( - f"Unable to extract text after attempting {request_count} times" - ) - time.sleep(poll_interval) - - return status - - def _extract_async(self, whisper_hash: str) -> str: - """Makes an async extraction with LLMWhisperer. - - Polls and checks the status first before proceeding to retrieve once. - - Args: - whisper_hash (str): Identifier of the extraction - - Returns: - str: Extracted contents from the file - """ - logger.info(f"Extracting async for whisper hash: {whisper_hash}") - - headers: dict[str, Any] = self._get_request_headers() - params = { - WhisperStatus.WHISPER_HASH: whisper_hash, - WhispererConfig.OUTPUT_JSON: WhispererDefaults.OUTPUT_JSON, - } - - # Polls in fixed intervals and checks status - self._check_status_until_ready( - whisper_hash=whisper_hash, headers=headers, params=params - ) - - retrieve_response = self._make_request( - request_method=HTTPMethod.GET, - request_endpoint=WhispererEndpoint.RETRIEVE, - headers=headers, - params=params, - ) - if retrieve_response.status_code == 200: - return retrieve_response.json() - else: - raise ExtractorError( - "Error retrieving from LLMWhisperer: " - f"{retrieve_response.status_code} - {retrieve_response.text}" - ) - - def _send_whisper_request( - self, - input_file_path: str, - fs: FileStorage | None = None, - enable_highlight: bool = False, - ) -> requests.Response: - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - headers = self._get_request_headers() - headers["Content-Type"] = "application/octet-stream" - params = self._get_whisper_params(enable_highlight) - - response: requests.Response - try: - response = self._make_request( - request_method=HTTPMethod.POST, - request_endpoint=WhispererEndpoint.WHISPER, - headers=headers, - params=params, - data=fs.read(path=input_file_path, mode="rb"), - ) - except OSError as e: - logger.error(f"OS error while reading {input_file_path}: {e}") - raise ExtractorError(str(e)) from e - return response - - def _extract_text_from_response( - self, - output_file_path: str | None, - response: requests.Response, - fs: FileStorage | None = None, - ) -> str: - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - output_json = {} - if response.status_code == 200: - output_json = response.json() - elif response.status_code == 202: - whisper_hash = response.json().get(WhisperStatus.WHISPER_HASH) - output_json = self._extract_async(whisper_hash=whisper_hash) - else: - raise ExtractorError("Couldn't extract text from file") - if output_file_path: - self._write_output_to_file( - output_json=output_json, output_file_path=Path(output_file_path), fs=fs - ) - return output_json.get("text", "") - - def _write_output_to_file( - self, - output_json: dict, - output_file_path: Path, - fs: FileStorage | None = None, - ) -> None: - """Write extracted text and metadata to output files. - - Writes the extracted text and metadata to the specified output file and - metadata file. - - Args: - output_json (dict): The dictionary containing the extracted data, - with "text" as the key for the main content. - output_file_path (Path): The file path where the extracted text - should be written. - - Raises: - ExtractorError: If there is an error while writing the output file. - """ - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - try: - text_output = output_json.get("text", "") - logger.info(f"Writing output to {output_file_path}") - fs.write( - path=output_file_path, - mode="w", - encoding="utf-8", - data=text_output, - ) - try: - # Define the directory of the output file and metadata paths - output_dir = output_file_path.parent - metadata_dir = output_dir / "metadata" - metadata_file_name = output_file_path.with_suffix(".json").name - metadata_file_path = metadata_dir / metadata_file_name - # Ensure the metadata directory exists - fs.mkdir(str(metadata_dir), create_parents=True) - # Remove the "text" key from the metadata - metadata = { - key: value for key, value in output_json.items() if key != "text" - } - metadata_json = json.dumps(metadata, ensure_ascii=False, indent=4) - logger.info(f"Writing metadata to {metadata_file_path}") - - fs.write( - path=metadata_file_path, - mode="w", - encoding="utf-8", - data=metadata_json, - ) - except Exception as e: - logger.error(f"Error while writing metadata to {metadata_file_path}: {e}") - - except Exception as e: - logger.error(f"Error while writing {output_file_path}: {e}") - raise ExtractorError(str(e)) from e - - def process( - self, - input_file_path: str, - output_file_path: str | None = None, - fs: FileStorage | None = None, - **kwargs: dict[Any, Any], - ) -> TextExtractionResult: - """Used to extract text from documents. - - Args: - input_file_path (str): Path to file that needs to be extracted - output_file_path (Optional[str], optional): File path to write - extracted text into, if None doesn't write to a file. - Defaults to None. - - Returns: - str: Extracted text - """ - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - response: requests.Response = self._send_whisper_request( - input_file_path, - fs, - bool(kwargs.get(X2TextConstants.ENABLE_HIGHLIGHT, False)), - ) - - metadata = TextExtractionMetadata( - whisper_hash=response.headers.get(X2TextConstants.WHISPER_HASH, "") - ) - - return TextExtractionResult( - extracted_text=self._extract_text_from_response( - output_file_path, response, fs - ), - extraction_metadata=metadata, - ) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json deleted file mode 100644 index d957d1b27d..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "title": "LLMWhisperer v1 Text Extractor", - "type": "object", - "required": [ - "adapter_name", - "unstract_key", - "url" - ], - "description": "LLMWhisperer v1 is deprecated, use the cheaper and faster [LLMWhisperer v2](https://docs.unstract.com/llmwhisperer/llm_whisperer/faqs/v1_to_v2/) instead.", - "properties": { - "adapter_name": { - "type": "string", - "title": "Name", - "default": "", - "description": "Provide a unique name for this adapter instance. Example: LLMWhisperer 1" - }, - "url": { - "type": "string", - "title": "URL", - "format": "uri", - "default": "https://llmwhisperer-api.unstract.com", - "description": "Provide the URL of the LLMWhisperer service. Please note that this version of LLMWhisperer is deprecated." - }, - "unstract_key": { - "type": "string", - "title": "LLMWhisperer Key", - "format": "password", - "description": "API key obtained from the [Unstract developer portal](https://unstract-api-resource.developer.azure-api.net)" - }, - "mode": { - "type": "string", - "title": "Mode", - "enum": [ - "native_text", - "low_cost", - "high_quality", - "form" - ], - "default": "form", - "description": "Processing mode to use, described in the [LLMWhisperer v1 documentation](https://docs.unstract.com/llmwhisperer/1.0.0/llm_whisperer/apis/llm_whisperer_text_extraction_api/#processing-modes)" - }, - "output_mode": { - "type": "string", - "title": "Output Mode", - "enum": [ - "line-printer", - "dump-text", - "text" - ], - "default": "line-printer", - "description": "Output mode to use, described in the [LLMWhisperer v1 documentation](https://docs.unstract.com/llmwhisperer/1.0.0/llm_whisperer/apis/llm_whisperer_text_extraction_api/#output-modes)" - }, - - "line_splitter_tolerance": { - "type": "number", - "title": "Line Splitter Tolerance", - "default": 0.4, - "description": "Reduce this value to split lines less often, increase to split lines more often. Useful when PDFs have multi column layout with text in each column that is not aligned." - }, - "horizontal_stretch_factor": { - "type": "number", - "title": "Horizontal Stretch Factor", - "default": 1.0, - "description": "Increase this value to stretch text horizontally, decrease to compress text horizontally. Useful when multi column text merge with each other." - }, - "pages_to_extract": { - "type": "string", - "title": "Page number(s) or range to extract", - "default": "", - "pattern": "^(\\s*\\d+-\\d+|\\s*\\d+-|\\s*\\d+|^$)(,\\d+-\\d+|,\\d+-|,\\d+)*$", - "description": "Specify the range of pages to extract (e.g., 1-5, 7, 10-12, 50-). Leave it empty to extract all pages." - }, - "page_seperator": { - "type": "string", - "title": "Page separator", - "default": "<<< >>>", - "description": "Specify a pattern to separate the pages in the document (e.g., <<< {{page_no}} >>>, <<< >>>). This pattern will be inserted at the end of every page. Omit {{page_no}} if you don't want to include the page number in the separator." - } - }, - "if": { - "anyOf": [ - { - "properties": { - "mode": { - "const": "low_cost" - } - } - }, - { - "properties": { - "mode": { - "const": "high_quality" - } - } - }, - { - "properties": { - "mode": { - "const": "form" - } - } - } - ] - }, - "then": { - "properties": { - "median_filter_size": { - "type": "integer", - "title": "Median Filter Size", - "default": 0, - "description": "The size of the median filter to use for pre-processing the image during OCR based extraction. Useful to eliminate scanning artifacts and low quality JPEG artifacts. Default is 0 if the value is not explicitly set. Available only in the Enterprise version." - }, - "gaussian_blur_radius": { - "type": "number", - "title": "Gaussian Blur Radius", - "default": 0.0, - "description": "The radius of the gaussian blur to use for pre-processing the image during OCR based extraction. Useful to eliminate noise from the image. Default is 0.0 if the value is not explicitly set. Available only in the Enterprise version." - }, - "mark_vertical_lines": { - "type": "boolean", - "title": "Mark Vertical Lines", - "default": false, - "description": "Detect vertical lines in the document and replicate the same using text (using \"|\" symbol). Use this for displaying tables with borders." - }, - "mark_horizontal_lines": { - "type": "boolean", - "title": "Mark Horizontal Lines", - "default": false, - "description": "Detect horizontal lines in the document and replicate the same using text (using \"-\" symbol). Use this for displaying tables with borders and other horizontal serperators found in the document." - } - }, - "required": [ - "median_filter_size", - "gaussian_blur_radius" - ] - } -} diff --git a/unstract/workflow-execution/src/unstract/workflow_execution/constants.py b/unstract/workflow-execution/src/unstract/workflow_execution/constants.py index 3786706cd2..fbdc52cc73 100644 --- a/unstract/workflow-execution/src/unstract/workflow_execution/constants.py +++ b/unstract/workflow-execution/src/unstract/workflow_execution/constants.py @@ -16,8 +16,6 @@ class ToolRuntimeVariable: PLATFORM_SERVICE_API_KEY = "PLATFORM_SERVICE_API_KEY" X2TEXT_HOST = "X2TEXT_HOST" X2TEXT_PORT = "X2TEXT_PORT" - ADAPTER_LLMW_POLL_INTERVAL = "ADAPTER_LLMW_POLL_INTERVAL" - ADAPTER_LLMW_MAX_POLLS = "ADAPTER_LLMW_MAX_POLLS" ADAPTER_LLMW_WAIT_TIMEOUT = "ADAPTER_LLMW_WAIT_TIMEOUT" EXECUTION_BY_TOOL = "EXECUTION_BY_TOOL" WORKFLOW_EXECUTION_DIR_PREFIX = "WORKFLOW_EXECUTION_DIR_PREFIX" diff --git a/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py b/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py index 040c9485cb..41a69ae4aa 100644 --- a/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py +++ b/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py @@ -46,12 +46,6 @@ def __init__( ) self.x2text_host = ToolsUtils.get_env(ToolRV.X2TEXT_HOST, raise_exception=True) self.x2text_port = ToolsUtils.get_env(ToolRV.X2TEXT_PORT, raise_exception=True) - self.llmw_poll_interval = ToolsUtils.get_env( - ToolRV.ADAPTER_LLMW_POLL_INTERVAL, raise_exception=False - ) - self.llmw_max_polls = ToolsUtils.get_env( - ToolRV.ADAPTER_LLMW_MAX_POLLS, raise_exception=False - ) self.llmw_wait_timeout = ToolsUtils.get_env( ToolRV.ADAPTER_LLMW_WAIT_TIMEOUT, raise_exception=False ) @@ -242,10 +236,6 @@ def get_tool_environment_variables(self) -> dict[str, Any]: or "mymaster", } # For async LLM Whisperer extraction - if self.llmw_poll_interval: - platform_vars[ToolRV.ADAPTER_LLMW_POLL_INTERVAL] = self.llmw_poll_interval - if self.llmw_max_polls: - platform_vars[ToolRV.ADAPTER_LLMW_MAX_POLLS] = self.llmw_max_polls if self.llmw_wait_timeout: platform_vars[ToolRV.ADAPTER_LLMW_WAIT_TIMEOUT] = self.llmw_wait_timeout return platform_vars diff --git a/workers/executor/executors/legacy_executor.py b/workers/executor/executors/legacy_executor.py index b672d56ab6..eb146d9cb5 100644 --- a/workers/executor/executors/legacy_executor.py +++ b/workers/executor/executors/legacy_executor.py @@ -29,7 +29,6 @@ from unstract.sdk1.adapters.exceptions import AdapterError from unstract.sdk1.adapters.x2text.constants import X2TextConstants -from unstract.sdk1.adapters.x2text.llm_whisperer.src import LLMWhisperer from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 from unstract.sdk1.constants import LogLevel from unstract.sdk1.execution.context import ExecutionContext, Operation @@ -249,9 +248,7 @@ def _handle_extract(self, context: ExecutionContext) -> ExecutionResult: f"Extracting text using `{extractor_name}`" + (" (with highlight)" if enable_highlight else "") ) - if enable_highlight and isinstance( - x2text.x2text_instance, (LLMWhisperer, LLMWhispererV2) - ): + if enable_highlight and isinstance(x2text.x2text_instance, LLMWhispererV2): process_response: TextExtractionResult = x2text.process( input_file_path=file_path, output_file_path=output_file_path, diff --git a/workers/sample.env b/workers/sample.env index 6a80eb4556..f64415c921 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -329,8 +329,6 @@ ADAPTER_LLMW_WAIT_TIMEOUT=900 # 15 mins ADAPTER_LLMW_MAX_RETRIES=3 ADAPTER_LLMW_RETRY_MIN_WAIT=1.0 ADAPTER_LLMW_RETRY_MAX_WAIT=60.0 -ADAPTER_LLMW_POLL_INTERVAL=30 -ADAPTER_LLMW_MAX_POLLS=1000 # Tool Runner UNSTRACT_RUNNER_HOST=http://unstract-runner diff --git a/workers/tests/test_legacy_executor_extract.py b/workers/tests/test_legacy_executor_extract.py index 0711d2255a..0616eb2810 100644 --- a/workers/tests/test_legacy_executor_extract.py +++ b/workers/tests/test_legacy_executor_extract.py @@ -2,7 +2,7 @@ Verifies: 1. Happy path: extraction returns success with extracted_text -2. With highlight (LLMWhisperer): enable_highlight passed through +2. With highlight (LLMWhisperer V2): enable_highlight passed through 3. Without highlight (non-Whisperer): enable_highlight NOT passed 4. AdapterError → failure result 5. Missing required params → failure result @@ -18,12 +18,14 @@ from unittest.mock import MagicMock, patch import pytest - from executor.executors.constants import ( FileStorageKeys, +) +from executor.executors.constants import ( IndexingConstants as IKeys, ) from executor.executors.exceptions import LegacyExecutorError + from unstract.sdk1.adapters.x2text.constants import X2TextConstants from unstract.sdk1.adapters.x2text.dto import ( TextExtractionMetadata, @@ -100,9 +102,7 @@ def test_extract_returns_success(self, mock_x2text_cls, mock_get_fs): @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_extract_passes_correct_params_to_x2text( - self, mock_x2text_cls, mock_get_fs - ): + def test_extract_passes_correct_params_to_x2text(self, mock_x2text_cls, mock_get_fs): _register_legacy() executor = ExecutorRegistry.get("legacy") @@ -131,16 +131,14 @@ def test_extract_passes_correct_params_to_x2text( ) -# --- 2. With highlight (LLMWhisperer) --- +# --- 2. With highlight (LLMWhisperer V2) --- class TestWithHighlight: @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_highlight_with_whisperer_v2( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): + def test_highlight_with_whisperer_v2(self, mock_x2text_cls, mock_get_fs, mock_dump): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -171,39 +169,6 @@ def test_highlight_with_whisperer_v2( call_kwargs = mock_x2text.process.call_args.kwargs assert call_kwargs.get("enable_highlight") is True - @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_highlight_with_whisperer_v1( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): - from unstract.sdk1.adapters.x2text.llm_whisperer.src import LLMWhisperer - - _register_legacy() - executor = ExecutorRegistry.get("legacy") - - mock_x2text = MagicMock() - mock_x2text.process.return_value = _mock_process_response() - mock_x2text.x2text_instance = MagicMock(spec=LLMWhisperer) - mock_x2text_cls.return_value = mock_x2text - mock_get_fs.return_value = MagicMock() - - ctx = _make_context( - executor_params={ - "x2text_instance_id": "x2t-whisperer-v1", - "file_path": "/data/test.pdf", - "platform_api_key": "sk-key", - "enable_highlight": True, - "execution_data_dir": "/data/run", - "tool_execution_metadata": {}, - } - ) - result = executor.execute(ctx) - - assert result.success is True - call_kwargs = mock_x2text.process.call_args.kwargs - assert call_kwargs.get("enable_highlight") is True - # --- 3. Without highlight (non-Whisperer) --- @@ -239,9 +204,7 @@ def test_no_highlight_for_non_whisperer(self, mock_x2text_cls, mock_get_fs): @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_highlight_false_skips_whisperer_branch( - self, mock_x2text_cls, mock_get_fs - ): + def test_highlight_false_skips_whisperer_branch(self, mock_x2text_cls, mock_get_fs): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -356,9 +319,7 @@ class TestMetadataToolSource: @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_tool_source_writes_metadata( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): + def test_tool_source_writes_metadata(self, mock_x2text_cls, mock_get_fs, mock_dump): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -391,12 +352,8 @@ def test_tool_source_writes_metadata( # ToolUtils.dump_json should have been called mock_dump.assert_called_once() dump_kwargs = mock_dump.call_args.kwargs - assert dump_kwargs["file_to_dump"] == str( - Path("/run/data") / IKeys.METADATA_FILE - ) - assert dump_kwargs["json_to_dump"] == { - X2TextConstants.WHISPER_HASH: "whash-456" - } + assert dump_kwargs["file_to_dump"] == str(Path("/run/data") / IKeys.METADATA_FILE) + assert dump_kwargs["json_to_dump"] == {X2TextConstants.WHISPER_HASH: "whash-456"} assert dump_kwargs["fs"] is mock_fs # tool_exec_metadata should be updated in-place assert tool_meta[X2TextConstants.WHISPER_HASH] == "whash-456" @@ -409,9 +366,7 @@ class TestMetadataIDESource: @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_ide_source_skips_metadata( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): + def test_ide_source_skips_metadata(self, mock_x2text_cls, mock_get_fs, mock_dump): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -445,6 +400,7 @@ class TestFileUtilsRouting: @patch("executor.executors.file_utils.EnvHelper.get_storage") def test_ide_returns_permanent_storage(self, mock_get_storage): from executor.executors.file_utils import FileUtils + from unstract.sdk1.file_storage.constants import StorageType mock_get_storage.return_value = MagicMock() @@ -458,6 +414,7 @@ def test_ide_returns_permanent_storage(self, mock_get_storage): @patch("executor.executors.file_utils.EnvHelper.get_storage") def test_tool_returns_temporary_storage(self, mock_get_storage): from executor.executors.file_utils import FileUtils + from unstract.sdk1.file_storage.constants import StorageType mock_get_storage.return_value = MagicMock() @@ -481,9 +438,7 @@ def test_invalid_source_raises_value_error(self): class TestOrchestratorIntegration: @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_orchestrator_extract_returns_success( - self, mock_x2text_cls, mock_get_fs - ): + def test_orchestrator_extract_returns_success(self, mock_x2text_cls, mock_get_fs): _register_legacy() orchestrator = ExecutionOrchestrator() @@ -525,9 +480,7 @@ def eager_app(): class TestCeleryEager: @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_eager_extract_returns_success( - self, mock_x2text_cls, mock_get_fs, eager_app - ): + def test_eager_extract_returns_success(self, mock_x2text_cls, mock_get_fs, eager_app): _register_legacy() mock_x2text = MagicMock() @@ -551,11 +504,10 @@ def test_eager_extract_returns_success( class TestExecuteErrorCatching: @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_extraction_error_caught_by_execute( - self, mock_x2text_cls, mock_get_fs - ): + def test_extraction_error_caught_by_execute(self, mock_x2text_cls, mock_get_fs): """ExtractionError (a LegacyExecutorError) is caught in execute() - and mapped to ExecutionResult.failure().""" + and mapped to ExecutionResult.failure(). + """ from unstract.sdk1.adapters.exceptions import AdapterError _register_legacy()