diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index bcf6ac74f8..1897d58143 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -20,6 +20,7 @@ from unstract.sdk1.adapters.base import Adapter from unstract.sdk1.adapters.x2text.constants import X2TextConstants from unstract.sdk1.constants import AdapterTypes +from unstract.sdk1.constants import Common as common from unstract.sdk1.embedding import EmbeddingCompat from unstract.sdk1.exceptions import SdkError from unstract.sdk1.llm import LLM @@ -28,6 +29,8 @@ logger = logging.getLogger(__name__) +UNAVAILABLE_ADAPTER_ICON = "⚠️" + try: from plugins.subscription.time_trials.subscription_adapter import add_unstract_key except ImportError: @@ -109,6 +112,31 @@ def get_adapter_data_with_key(adapter_id: str, key_value: str) -> Any: raise InValidAdapterId() return updated_adapters[0].get(key_value) + @staticmethod + def get_display_info(adapter: AdapterInstance) -> tuple[str, str]: + """Icon and model label for an adapter, as (icon, model). + + Display data only, no credentials. Never raises - falls back to the + warning icon and the adapter id's provider prefix. + """ + icon = UNAVAILABLE_ADAPTER_ICON + if adapter.is_available: + try: + icon = ( + AdapterProcessor.get_adapter_data_with_key( + adapter.adapter_id, common.ICON + ) + or UNAVAILABLE_ADAPTER_ICON + ) + except Exception as e: + logger.warning(f"No icon for adapter {adapter.adapter_id}: {e}") + try: + model = adapter.metadata.get("model") + except Exception as e: + logger.warning(f"No metadata for adapter {adapter.adapter_id}: {e}") + model = None + return icon, model or adapter.adapter_id.split("|")[0] + @staticmethod def test_adapter(adapter_id: str, adapter_metadata: dict[str, Any]) -> bool: try: @@ -214,27 +242,6 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None: else: raise InternalServiceError() - @staticmethod - def get_adapter_instance_by_id(adapter_instance_id: str) -> Adapter: - """Get the adapter instance by its ID. - - Parameters: - - adapter_instance_id (str): The ID of the adapter instance. - - Returns: - - Adapter: The adapter instance with the specified ID. - - Raises: - - Exception: If there is an error while fetching the adapter instance. - """ - try: - adapter = AdapterInstance.objects.get(id=adapter_instance_id) - except Exception as e: - logger.error(f"Unable to fetch adapter: {e}") - if not adapter: - logger.error("Unable to fetch adapter") - return adapter.adapter_name - @staticmethod def get_adapters_by_type( adapter_type: AdapterTypes, user: User diff --git a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py index 008fed3850..e276f90525 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py @@ -9,33 +9,39 @@ logger = logging.getLogger(__name__) +# Adapter FK -> label shown on the Prompt Studio output tiles. +ADAPTER_LABELS = ( + (ProfileManagerKeys.LLM, "LLM"), + (ProfileManagerKeys.EMBEDDING_MODEL, "Embedding Model"), + (ProfileManagerKeys.VECTOR_STORE, "Vector Store"), + (ProfileManagerKeys.X2TEXT, "Text Extractor"), +) + class ProfileManagerSerializer(AuditSerializer): class Meta: model = ProfileManager fields = "__all__" - # View owns uniqueness (IntegrityError->DuplicateData on create); drop - # the DRF auto-validator that 400s on re-save / PUT before the view runs. + # Uniqueness is enforced by the view; the auto-validator 400s on re-save. validators = [] def to_representation(self, instance): # type: ignore + """Resolve the adapter FKs to the name, model and icon the UI renders. + + Not filtered by adapter access - display data only, no credentials. + """ rep: dict[str, str] = super().to_representation(instance) - llm = rep[ProfileManagerKeys.LLM] - embedding = rep[ProfileManagerKeys.EMBEDDING_MODEL] - vector_db = rep[ProfileManagerKeys.VECTOR_STORE] - x2text = rep[ProfileManagerKeys.X2TEXT] - if llm: - rep[ProfileManagerKeys.LLM] = AdapterProcessor.get_adapter_instance_by_id(llm) - if embedding: - rep[ProfileManagerKeys.EMBEDDING_MODEL] = ( - AdapterProcessor.get_adapter_instance_by_id(embedding) - ) - if vector_db: - rep[ProfileManagerKeys.VECTOR_STORE] = ( - AdapterProcessor.get_adapter_instance_by_id(vector_db) - ) - if x2text: - rep[ProfileManagerKeys.X2TEXT] = AdapterProcessor.get_adapter_instance_by_id( - x2text - ) + conf: dict[str, str] = {} + for field, label in ADAPTER_LABELS: + adapter = getattr(instance, field, None) + if not adapter: + continue + icon, model = AdapterProcessor.get_display_info(adapter) + rep[field] = adapter.adapter_name + conf[label] = model + if field == ProfileManagerKeys.LLM: + rep["icon"] = icon + if conf: + conf["Profile Name"] = instance.profile_name + rep["conf"] = conf return rep diff --git a/backend/prompt_studio/prompt_profile_manager_v2/tests/__init__.py b/backend/prompt_studio/prompt_profile_manager_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py b/backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py new file mode 100644 index 0000000000..8c790164c7 --- /dev/null +++ b/backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py @@ -0,0 +1,93 @@ +"""Profile serializer resolves adapter FKs to display data without an access check. + +The DRF base is patched out so the assertions cover only that resolution. +""" + +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from backend.serializers import AuditSerializer + +from prompt_studio.prompt_profile_manager_v2.serializers import ProfileManagerSerializer + + +def _adapter(name: str, model: str) -> SimpleNamespace: + return SimpleNamespace(adapter_name=name, model=model) + + +def _represent(instance: SimpleNamespace, base_rep: dict) -> dict: + with ( + patch.object(AuditSerializer, "to_representation", return_value=base_rep), + patch( + "prompt_studio.prompt_profile_manager_v2.serializers." + "AdapterProcessor.get_display_info", + side_effect=lambda adapter: ("openai.png", adapter.model), + ), + ): + return ProfileManagerSerializer().to_representation(instance) + + +class ProfileDisplayInfoTests(unittest.TestCase): + def test_display_info_resolved_without_adapter_access(self) -> None: + instance = SimpleNamespace( + profile_name="Prod", + llm=_adapter("Shared GPT", "gpt-4o"), + embedding_model=_adapter("Shared Embed", "text-embedding-3-small"), + vector_store=_adapter("Shared Qdrant", "qdrant"), + x2text=_adapter("Shared LLMW", "llmwhisperer"), + ) + base_rep = { + field: "some-uuid" + for field in ("llm", "embedding_model", "vector_store", "x2text") + } + + rep = _represent(instance, base_rep) + + self.assertEqual( + rep["conf"], + { + "LLM": "gpt-4o", + "Embedding Model": "text-embedding-3-small", + "Vector Store": "qdrant", + "Text Extractor": "llmwhisperer", + "Profile Name": "Prod", + }, + ) + # Only the LLM contributes the tile icon. + self.assertEqual(rep["icon"], "openai.png") + # FK ids are replaced by the adapter names. + self.assertEqual(rep["llm"], "Shared GPT") + + def test_unset_adapters_are_skipped(self) -> None: + instance = SimpleNamespace( + profile_name="Half configured", + llm=_adapter("Shared GPT", "gpt-4o"), + embedding_model=None, + vector_store=None, + x2text=None, + ) + + rep = _represent(instance, {"llm": "some-uuid", "embedding_model": None}) + + self.assertEqual( + rep["conf"], {"LLM": "gpt-4o", "Profile Name": "Half configured"} + ) + self.assertIsNone(rep["embedding_model"]) + + def test_profile_with_no_adapters_has_empty_conf(self) -> None: + instance = SimpleNamespace( + profile_name="Empty", + llm=None, + embedding_model=None, + vector_store=None, + x2text=None, + ) + + rep = _represent(instance, {}) + + # No "Profile Name" either - the tile has nothing to show. + self.assertEqual(rep["conf"], {}) + self.assertNotIn("icon", rep) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index d8d9bbb65f..bce344d055 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -36,7 +36,10 @@ def get_permissions(self) -> list[Any]: return [IsOwnerOrSharedUserOrSharedToOrg()] def get_queryset(self) -> QuerySet | None: - queryset = ProfileManager.objects.for_user(self.request.user) + # Serializer reads all four adapters per profile for the display info + queryset = ProfileManager.objects.for_user(self.request.user).select_related( + "llm", "embedding_model", "vector_store", "x2text" + ) filter_args = FilterHelper.build_filter_args( self.request, ProfileManagerKeys.CREATED_BY, diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx index 5cb5d099b1..03816f24f4 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx @@ -83,7 +83,6 @@ function PromptCardItems({ indexDocs, isSimplePromptStudio, isPublicSource, - adapters, selectedHighlight, details, singlePassExtractMode, @@ -114,32 +113,6 @@ function PromptCardItems({ ); }, [allTableSettings]); - const getModelOrAdapterId = (profile, adapters) => { - const result = { conf: {} }; - const keys = [ - { key: "llm", label: "LLM" }, - { key: "embedding_model", label: "Embedding Model" }, - { key: "vector_store", label: "Vector Store" }, - { key: "x2text", label: "Text Extractor" }, - ]; - - keys.forEach((key) => { - const adapterName = profile[key.key]; - const adapter = adapters?.find( - (adapter) => adapter?.adapter_name === adapterName, - ); - if (adapter) { - result.conf[key.label] = - adapter?.model || adapter?.adapter_id?.split("|")[0]; - if (adapter?.adapter_type === "LLM") { - result.icon = adapter?.icon; - } - result.conf["Profile Name"] = profile?.profile_name; - } - }); - return result; - }; - const getUpdatedCoverage = (promptId, singlePass, promptOutputs) => { let updatedCoverage = null; Object.keys(promptOutputs).forEach((key) => { @@ -166,18 +139,18 @@ function PromptCardItems({ getUpdatedCoverage(promptId, singlePassExtractMode, promptOutputs) || coverageCountData; - const getAdapterInfo = async (adapterData) => { - // If simple prompt studio, return early + useEffect(() => { + setExpandCard(true); + }, [isSinglePassExtractLoading]); + + useEffect(() => { if (isSimplePromptStudio) { return; } - - // Update llmProfiles with additional fields - const updatedProfiles = llmProfiles?.map((profile) => { - return { ...getModelOrAdapterId(profile, adapterData), ...profile }; - }); + // conf/icon come off the profile payload; the viewer may not have + // access to the project's adapters. setLlmProfileDetails( - updatedProfiles + (llmProfiles || []) .map((profile) => ({ ...profile, isDefault: profile?.profile_id === selectedLlmProfileId, @@ -192,15 +165,7 @@ function PromptCardItems({ return 0; }), ); - }; - - useEffect(() => { - setExpandCard(true); - }, [isSinglePassExtractLoading]); - - useEffect(() => { - getAdapterInfo(adapters); - }, [llmProfiles, selectedLlmProfileId]); + }, [llmProfiles, selectedLlmProfileId, isSimplePromptStudio]); return (
- + {isImageUrl(profile?.icon) ? ( + + ) : ( + + {profile?.icon} + + )} {displayLlmProfile && (
- + {isImageUrl(profile?.icon) ? ( + + ) : ( + + {profile?.icon} + + )} {profile?.conf?.LLM} diff --git a/frontend/src/helpers/GetStaticData.js b/frontend/src/helpers/GetStaticData.js index 10b0fdb58d..a1f71a88c8 100644 --- a/frontend/src/helpers/GetStaticData.js +++ b/frontend/src/helpers/GetStaticData.js @@ -476,6 +476,12 @@ const isNonNegativeNumber = (value) => { return typeof value === "number" && !isNaN(value) && value >= 0; }; +// Icons are image URLs, except the emoji fallbacks used for unavailable +// adapters. Detect the URL rather than the emoji - compound (ZWJ) emoji break +// length heuristics. +const isImageUrl = (value) => + typeof value === "string" && /^(https?:\/\/|\/|data:image\/)/.test(value); + // Default token usage object with all counts initialized to 0 const defaultTokenUsage = { embedding_tokens: 0, @@ -780,7 +786,6 @@ export { formatSecondsToHMS, formatTimeDisplay, formattedDateTime, - timeAgo, formattedDateTimeWithSeconds, generateApiRunStatusId, generateCoverageKey, @@ -797,6 +802,7 @@ export { getSequenceNumber, getTimeForLogs, homePagePath, + isImageUrl, isJson, isNonNegativeNumber, isValidJsonKey, @@ -817,6 +823,7 @@ export { sourceTypes, THEME, TRIAL_PLAN, + timeAgo, titleCase, toolIdeOutput, UNSTRACT_ADMIN,