From 752705e09cad5328fdfefc751eff190d9fb894fc Mon Sep 17 00:00:00 2001 From: Ritwik G Date: Fri, 6 Nov 2026 16:21:15 +0530 Subject: [PATCH 1/2] feat(global_search): split adapters into four types by kind Replace the single `adapter` search type with `llm`, `vector_db`, `embedding` and `text_extractor`, one per v1 rail destination, each with its own limit and has_more. OCR adapters are no longer returned. All four keep the shared adapter.detail / adapter.info route keys, which the notifications inbox also uses; adapter_type in the params picks the page. UN-4628 Co-Authored-By: Claude Opus 5.5 --- backend/global_search/README.md | 13 +++- backend/global_search/constants.py | 7 +- backend/global_search/oss_providers.py | 66 ++++++++++++++++--- backend/global_search/tests/base.py | 26 +++++++- backend/global_search/tests/test_budget.py | 8 ++- backend/global_search/tests/test_contract.py | 12 ++-- .../global_search/tests/test_visibility.py | 41 +++++++++--- 7 files changed, 142 insertions(+), 31 deletions(-) diff --git a/backend/global_search/README.md b/backend/global_search/README.md index a20d7d47c9..02e165614a 100644 --- a/backend/global_search/README.md +++ b/backend/global_search/README.md @@ -66,7 +66,7 @@ changes. - `context` is a single display-only line, or `null`. Do not parse it. - `route.key` is a route-registry name, not a URL. `route.params` values are strings. It is one of the keys the table below lists for the result's - type; adapters have two (see below the table). A client that does not + type; the adapter types have two (see below the table). A client that does not know a key should render the result without a link, never guess a screen. - `type` is an open string. A client should render an unknown type with its @@ -117,9 +117,18 @@ empty 200 (see the errors above). | 40 | `api_deployment` | API deployments | `api_deployment.detail` | `id` | parent workflow name | | 50 | `etl_pipeline` | ETL pipelines | `etl_pipeline.detail` | `id` | parent workflow name | | 60 | `task_pipeline` | Task pipelines | `task_pipeline.detail` | `id` | parent workflow name | -| 80 | `adapter` | Adapters | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider | +| 80 | `llm` | LLMs | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider | +| 81 | `vector_db` | Vector DBs | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider | +| 82 | `embedding` | Embedding | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider | +| 83 | `text_extractor` | Text Extractor | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider | | 90 | `connector` | Connectors | `connector.detail` | `id` | provider | +Adapters are split by kind into four types, one per rail destination +(`adapter_type` `LLM`, `VECTOR_DB`, `EMBEDDING` and `X2TEXT`), each with its +own `limit` and `has_more`. OCR adapters are not searchable. All four kinds +share the `adapter.*` route keys, as the notifications inbox does; +`adapter_type` in the params says which adapter page to open. + An adapter opens on `adapter.detail` only for a caller who may edit it (owner or co-owner, org admin, service account), the same rule as the adapter `retrieve` endpoint. Everyone else gets `adapter.info`, with the diff --git a/backend/global_search/constants.py b/backend/global_search/constants.py index ffec01c2a7..ab595dc424 100644 --- a/backend/global_search/constants.py +++ b/backend/global_search/constants.py @@ -41,5 +41,10 @@ class OSSSearchType: API_DEPLOYMENT = "api_deployment" ETL_PIPELINE = "etl_pipeline" TASK_PIPELINE = "task_pipeline" - ADAPTER = "adapter" + # Adapters split by kind, one type per rail destination. OCR adapters + # are not searchable: v1 drops OCR (decided 5 Nov 2026). + LLM = "llm" + VECTOR_DB = "vector_db" + EMBEDDING = "embedding" + TEXT_EXTRACTOR = "text_extractor" CONNECTOR = "connector" diff --git a/backend/global_search/oss_providers.py b/backend/global_search/oss_providers.py index 2ab6a17963..2071621bb8 100644 --- a/backend/global_search/oss_providers.py +++ b/backend/global_search/oss_providers.py @@ -11,6 +11,7 @@ import dataclasses from typing import Any +from adapter_processor_v2.constants import AdapterKeys from adapter_processor_v2.models import AdapterInstance from api_v2.models import APIDeployment from connector_v2.models import ConnectorInstance @@ -147,24 +148,38 @@ class TaskPipelineProvider(_PipelineProvider): class AdapterProvider(ModelSearchProvider): - type_key = OSSSearchType.ADAPTER - label = "Adapters" - order = 80 + """Adapters of one kind (``adapter_type``), one search type per rail + destination. Not registered itself; the kinds below are. + + All kinds share the ``adapter.detail`` / ``adapter.info`` route keys, + which the notifications inbox uses too; ``adapter_type`` in the route + params tells the client which adapter page to open. + """ + + adapter_type: str name_fields = ("adapter_name",) description_field = "description" + @property + def route_key(self) -> str: + return "adapter.detail" + + @property + def info_route_key(self) -> str: + return "adapter.info" + def visible_queryset(self, user: Any) -> QuerySet[Any]: # Frictionless adapters stay in the adapter list, where any member # may delete them, but nobody can open their detail view: retrieve # refuses them for everyone, admins included (IsFrictionLessAdapter). - return AdapterInstance.objects.for_user(user).exclude( - Q(adapter_id__startswith=NO_OP_ADAPTER_PREFIX) | Q(is_friction_less=True) + return ( + AdapterInstance.objects.for_user(user) + .filter(adapter_type=self.adapter_type) + .exclude( + Q(adapter_id__startswith=NO_OP_ADAPTER_PREFIX) | Q(is_friction_less=True) + ) ) - @property - def info_route_key(self) -> str: - return f"{self.type_key}.info" - def annotate(self, queryset: QuerySet[Any], user: Any) -> QuerySet[Any]: # Same rule as IsFrictionLessAdapter, which gates retrieve: service # accounts, org admins and owners (creator or co-owner). Anyone else @@ -197,6 +212,34 @@ def route_params(self, obj: Model) -> dict[str, str]: return {"id": str(obj.pk), "adapter_type": obj.adapter_type} +class LLMAdapterProvider(AdapterProvider): + type_key = OSSSearchType.LLM + label = "LLMs" + order = 80 + adapter_type = AdapterKeys.LLM + + +class VectorDBAdapterProvider(AdapterProvider): + type_key = OSSSearchType.VECTOR_DB + label = "Vector DBs" + order = 81 + adapter_type = AdapterKeys.VECTOR_DB + + +class EmbeddingAdapterProvider(AdapterProvider): + type_key = OSSSearchType.EMBEDDING + label = "Embedding" + order = 82 + adapter_type = AdapterKeys.EMBEDDING + + +class TextExtractorAdapterProvider(AdapterProvider): + type_key = OSSSearchType.TEXT_EXTRACTOR + label = "Text Extractor" + order = 83 + adapter_type = AdapterKeys.X2TEXT + + class ConnectorProvider(ModelSearchProvider): type_key = OSSSearchType.CONNECTOR label = "Connectors" @@ -216,6 +259,9 @@ def context(self, obj: Model) -> str | None: APIDeploymentProvider, ETLPipelineProvider, TaskPipelineProvider, - AdapterProvider, + LLMAdapterProvider, + VectorDBAdapterProvider, + EmbeddingAdapterProvider, + TextExtractorAdapterProvider, ConnectorProvider, ) diff --git a/backend/global_search/tests/base.py b/backend/global_search/tests/base.py index 10cdd89719..ebe044adae 100644 --- a/backend/global_search/tests/base.py +++ b/backend/global_search/tests/base.py @@ -64,6 +64,7 @@ def build(org, user, name, description=""): def build_adapter(org, user, name, description="", **overrides): + """An LLM adapter unless ``adapter_type`` is overridden.""" fields = { "adapter_name": name, "adapter_id": "openai|test", @@ -77,6 +78,13 @@ def build_adapter(org, user, name, description="", **overrides): return AdapterInstance.objects.create(**fields) +def _adapter(adapter_type: str) -> Callable[..., AdapterInstance]: + def build(org, user, name, description=""): + return build_adapter(org, user, name, description, adapter_type=adapter_type) + + return build + + def build_connector(org, user, name, description=""): return ConnectorInstance.objects.create( connector_name=name, @@ -93,7 +101,10 @@ def build_connector(org, user, name, description=""): OSSSearchType.API_DEPLOYMENT: build_api_deployment, OSSSearchType.ETL_PIPELINE: _pipeline(Pipeline.PipelineType.ETL), OSSSearchType.TASK_PIPELINE: _pipeline(Pipeline.PipelineType.TASK), - OSSSearchType.ADAPTER: build_adapter, + OSSSearchType.LLM: _adapter("LLM"), + OSSSearchType.VECTOR_DB: _adapter("VECTOR_DB"), + OSSSearchType.EMBEDDING: _adapter("EMBEDDING"), + OSSSearchType.TEXT_EXTRACTOR: _adapter("X2TEXT"), OSSSearchType.CONNECTOR: build_connector, } @@ -102,7 +113,18 @@ def build_connector(org, user, name, description=""): OSSSearchType.PROMPT_STUDIO_PROJECT, OSSSearchType.WORKFLOW, OSSSearchType.API_DEPLOYMENT, - OSSSearchType.ADAPTER, + OSSSearchType.LLM, + OSSSearchType.VECTOR_DB, + OSSSearchType.EMBEDDING, + OSSSearchType.TEXT_EXTRACTOR, +} + +# The adapter kinds, keyed by type key; every one opens on adapter.detail. +ADAPTER_TYPES = { + OSSSearchType.LLM: "LLM", + OSSSearchType.VECTOR_DB: "VECTOR_DB", + OSSSearchType.EMBEDDING: "EMBEDDING", + OSSSearchType.TEXT_EXTRACTOR: "X2TEXT", } diff --git a/backend/global_search/tests/test_budget.py b/backend/global_search/tests/test_budget.py index d92f540fbd..11c78b40b0 100644 --- a/backend/global_search/tests/test_budget.py +++ b/backend/global_search/tests/test_budget.py @@ -39,7 +39,7 @@ from workflow_manager.workflow_v2.models.workflow import Workflow from global_search.oss_providers import OSS_PROVIDERS -from global_search.tests.base import search +from global_search.tests.base import ADAPTER_TYPES, search ROWS_PER_TYPE = 2_000 USERS = 200 @@ -139,13 +139,15 @@ def setUpTestData(cls) -> None: ), *AdapterInstance.objects.bulk_create( AdapterInstance( - adapter_name=_name("ad", i), + adapter_name=_name(f"ad{kind}", i), adapter_id="openai|seed", - adapter_type="LLM", + adapter_type=kind, adapter_metadata={}, description=_name("d", i), **common, ) + # Each adapter kind is its own search type. + for kind in ADAPTER_TYPES.values() for i in n ), *ConnectorInstance.objects.bulk_create( diff --git a/backend/global_search/tests/test_contract.py b/backend/global_search/tests/test_contract.py index 53ffe5fd1e..ed2bac929b 100644 --- a/backend/global_search/tests/test_contract.py +++ b/backend/global_search/tests/test_contract.py @@ -26,7 +26,7 @@ from global_search.constants import OSSSearchType as T from global_search.oss_providers import OSS_PROVIDERS, AdapterProvider, WorkflowProvider from global_search.providers import SearchProvider -from global_search.tests.base import OSS_BUILDERS, search +from global_search.tests.base import ADAPTER_TYPES, OSS_BUILDERS, search from global_search.views import GlobalSearchView APP_DIR = Path(__file__).parents[1] @@ -66,7 +66,7 @@ def parent(type_key: str) -> str: T.API_DEPLOYMENT: parent(T.API_DEPLOYMENT), T.ETL_PIPELINE: parent(T.ETL_PIPELINE), T.TASK_PIPELINE: parent(T.TASK_PIPELINE), - T.ADAPTER: "LLM · openai", + **{key: f"{kind} · openai" for key, kind in ADAPTER_TYPES.items()}, T.CONNECTOR: "minio", } for type_key, item in items.items(): @@ -74,11 +74,13 @@ def parent(type_key: str) -> str: result = results[type_key] self.assertEqual(result["context"], expected_context[type_key]) expected_params = {"id": str(item.pk)} - if type_key == T.ADAPTER: - expected_params["adapter_type"] = "LLM" + route_key = f"{type_key}.detail" + if type_key in ADAPTER_TYPES: + expected_params["adapter_type"] = ADAPTER_TYPES[type_key] + route_key = "adapter.detail" self.assertEqual( result["route"], - {"key": f"{type_key}.detail", "params": expected_params}, + {"key": route_key, "params": expected_params}, ) def test_owner_context_skips_service_accounts_and_takes_the_earliest(self) -> None: diff --git a/backend/global_search/tests/test_visibility.py b/backend/global_search/tests/test_visibility.py index 04bb457462..8f9d59e0aa 100644 --- a/backend/global_search/tests/test_visibility.py +++ b/backend/global_search/tests/test_visibility.py @@ -20,6 +20,7 @@ from global_search.constants import OSSSearchType from global_search.tests.base import ( + ADAPTER_TYPES, OSS_BUILDERS, _pipeline, build_adapter, @@ -100,15 +101,13 @@ def test_frictionless_adapters_are_never_returned(self) -> None: ) adapter.memberships.create(user=self.owner, role=ResourceRole.OWNER) for user in (self.owner, self.admin, self.service_account, self.outsider): - self.assertFalse( - self._found(user, OSSSearchType.ADAPTER, adapter), user.email - ) + self.assertFalse(self._found(user, OSSSearchType.LLM, adapter), user.email) def test_adapter_route_follows_who_may_edit_it(self) -> None: # adapter.detail is gated like adapter retrieve: owners, co-owners, # admins and service accounts. Anyone else who can list the adapter # opens the read-only adapter.info. - adapter = self._private(OSSSearchType.ADAPTER) + adapter = self._private(OSSSearchType.LLM) set_resource_share_groups(adapter, [self.group.id]) adapter.shared_to_org = True adapter.save() @@ -123,9 +122,7 @@ def test_adapter_route_follows_who_may_edit_it(self) -> None: } for user, route_key in expected.items(): with self.subTest(user=user.email): - response = search( - user, q=QUERY, types=OSSSearchType.ADAPTER, limit=20 - ) + response = search(user, q=QUERY, types=OSSSearchType.LLM, limit=20) (result,) = response.data["groups"][0]["results"] self.assertEqual( result["route"], @@ -158,7 +155,35 @@ def test_no_op_adapters_are_never_returned(self) -> None: adapter_id="noOpLlm|f673a5a2-90f9-40f5-94c0-9fbc663b7553", shared_to_org=True, ) - self.assertFalse(self._found(self.admin, OSSSearchType.ADAPTER, adapter)) + self.assertFalse(self._found(self.admin, OSSSearchType.LLM, adapter)) + + def test_each_adapter_kind_lands_only_in_its_own_type(self) -> None: + # One group per rail destination: an adapter is found under its own + # kind and no other, and OCR adapters under none (v1 drops OCR). + adapters = { + type_key: build_adapter( + self.org, + self.owner, + f"{QUERY}-{type_key}", + adapter_type=adapter_type, + shared_to_org=True, + ) + for type_key, adapter_type in ADAPTER_TYPES.items() + } + ocr = build_adapter( + self.org, self.owner, f"{QUERY}-ocr", adapter_type="OCR", shared_to_org=True + ) + response = search(self.admin, q=QUERY, types=",".join(ADAPTER_TYPES), limit=20) + found = { + group["type"]: {result["id"] for result in group["results"]} + for group in response.data["groups"] + } + self.assertEqual( + found, + {key: {str(adapter.pk)} for key, adapter in adapters.items()}, + ) + everything = search(self.admin, q=QUERY, limit=20) + self.assertNotIn(str(ocr.pk), result_ids(everything)) def test_default_and_app_pipelines_are_never_returned(self) -> None: for pipeline_type in (Pipeline.PipelineType.DEFAULT, Pipeline.PipelineType.APP): From 6766873f1c3672ce5990d948ff921a24468cb976 Mon Sep 17 00:00:00 2001 From: Ritwik G Date: Fri, 6 Nov 2026 16:27:37 +0530 Subject: [PATCH 2/2] docs(global_search): note the removed adapter type; fix inbox and fixture comments Round-1 self-review fixes: record in the README that the four adapter types replace the released `adapter` type (and that OCR results are dropped), say the inbox sends only adapter.info, drop a fixture comment that claimed every kind opens on adapter.detail, and list the adapter-kind narrowing in the module docstring. UN-4628 Co-Authored-By: Claude Opus 5.5 --- backend/global_search/README.md | 12 ++++++++++-- backend/global_search/oss_providers.py | 11 ++++++----- backend/global_search/tests/base.py | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/backend/global_search/README.md b/backend/global_search/README.md index 02e165614a..42a40ab033 100644 --- a/backend/global_search/README.md +++ b/backend/global_search/README.md @@ -126,8 +126,16 @@ empty 200 (see the errors above). Adapters are split by kind into four types, one per rail destination (`adapter_type` `LLM`, `VECTOR_DB`, `EMBEDDING` and `X2TEXT`), each with its own `limit` and `has_more`. OCR adapters are not searchable. All four kinds -share the `adapter.*` route keys, as the notifications inbox does; -`adapter_type` in the params says which adapter page to open. +share the `adapter.*` route keys (the notifications inbox sends `adapter.info` +with the same params); `adapter_type` in the params says which adapter page to +open. + +**Changed (UN-4628):** these four types replace the single `adapter` type +(order 80, label "Adapters") that the endpoint returned from v0.194.0. A +request for `types=adapter` now returns no adapter groups, since unknown keys +are ignored, and OCR adapters, which `adapter` returned, are no longer +returned at all. No released client used the `adapter` key when it was +removed: the frontend panel (P2-T10) was not built yet. An adapter opens on `adapter.detail` only for a caller who may edit it (owner or co-owner, org admin, service account), the same rule as the diff --git a/backend/global_search/oss_providers.py b/backend/global_search/oss_providers.py index 2071621bb8..6101cced8a 100644 --- a/backend/global_search/oss_providers.py +++ b/backend/global_search/oss_providers.py @@ -2,8 +2,8 @@ Each provider starts from the same ``Model.objects.for_user(user)`` its list view uses, so search never returns a row the caller could not open from the -list. Search may narrow further (pipeline type, No Op and frictionless -adapters), never widen. +list. Search may narrow further (pipeline type; adapter kind, which leaves +OCR adapters out; No Op and frictionless adapters), never widen. """ from __future__ import annotations @@ -151,9 +151,10 @@ class AdapterProvider(ModelSearchProvider): """Adapters of one kind (``adapter_type``), one search type per rail destination. Not registered itself; the kinds below are. - All kinds share the ``adapter.detail`` / ``adapter.info`` route keys, - which the notifications inbox uses too; ``adapter_type`` in the route - params tells the client which adapter page to open. + All kinds share the ``adapter.detail`` / ``adapter.info`` route keys + (the notifications inbox sends ``adapter.info`` with the same params); + ``adapter_type`` in the route params tells the client which adapter page + to open. """ adapter_type: str diff --git a/backend/global_search/tests/base.py b/backend/global_search/tests/base.py index ebe044adae..ab23f5f3c0 100644 --- a/backend/global_search/tests/base.py +++ b/backend/global_search/tests/base.py @@ -119,7 +119,7 @@ def build_connector(org, user, name, description=""): OSSSearchType.TEXT_EXTRACTOR, } -# The adapter kinds, keyed by type key; every one opens on adapter.detail. +# The adapter kinds, keyed by type key. ADAPTER_TYPES = { OSSSearchType.LLM: "LLM", OSSSearchType.VECTOR_DB: "VECTOR_DB",