From c33ca2ebff56d1c75a2a8c3a078913196976c5b1 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Thu, 13 Aug 2026 12:11:10 +0530 Subject: [PATCH 1/3] UN-3991 [FIX] Show model names on Prompt Studio tiles for shared users The output tiles resolved the model label by matching the profile against the access-filtered adapter list, so a project shared without its adapters rendered no icon and no model name. The profile payload now carries the display data directly, and the frontend renders it as-is. Co-Authored-By: Claude Opus 5 --- .../adapter_processor_v2/adapter_processor.py | 46 ++++----- .../prompt_profile_manager_v2/serializers.py | 46 +++++---- .../tests/__init__.py | 0 .../tests/test_profile_display_info.py | 93 +++++++++++++++++++ .../prompt_profile_manager_v2/views.py | 5 +- .../prompt-card/PromptCardItems.jsx | 53 ++--------- 6 files changed, 157 insertions(+), 86 deletions(-) create mode 100644 backend/prompt_studio/prompt_profile_manager_v2/tests/__init__.py create mode 100644 backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index bcf6ac74f8..847177b38d 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,28 @@ 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 + ) + 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 +239,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 ( Date: Thu, 13 Aug 2026 12:21:17 +0530 Subject: [PATCH 2/3] UN-3991 [FIX] Keep the warning icon when an adapter has no registry icon get_adapter_data_with_key returns the registry entry's value, so an adapter whose entry has no icon key yields None without raising. The except branch never fired and None replaced the fallback icon. Co-Authored-By: Claude Opus 5 --- backend/adapter_processor_v2/adapter_processor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index 847177b38d..1897d58143 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -122,8 +122,11 @@ def get_display_info(adapter: AdapterInstance) -> tuple[str, str]: icon = UNAVAILABLE_ADAPTER_ICON if adapter.is_available: try: - icon = AdapterProcessor.get_adapter_data_with_key( - adapter.adapter_id, common.ICON + 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}") From ca031c15b2f3582e96c55f55925c78f690a0032d Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Thu, 13 Aug 2026 12:29:28 +0530 Subject: [PATCH 3/3] UN-3991 [FIX] Render the adapter icon fallback as text, not an image src The icon is an emoji when the adapter is unavailable or has no registry icon, but the Prompt Studio output tiles passed it to antd Image as src, so it loaded as a URL and rendered a broken image. Co-Authored-By: Claude Opus 5 --- .../custom-tools/prompt-card/PromptOutput.jsx | 21 ++++++++++++------- .../prompt-card/PromptOutputsModal.jsx | 21 ++++++++++++------- frontend/src/helpers/GetStaticData.js | 9 +++++++- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx b/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx index b8aaa4b393..5daad81c92 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { displayPromptResult, generateApiRunStatusId, + isImageUrl, PROMPT_RUN_API_STATUSES, PROMPT_RUN_TYPES, } from "../../../helpers/GetStaticData"; @@ -355,13 +356,19 @@ function PromptOutput({ >
- + {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,