refactor(api): replace legacy recommended app retrieval stack (#40395)

This commit is contained in:
非法操作 authored and GitHub committed 2026-08-20 13:13:43 +00:00
1 parent de7e43a7ef
commit 1bb9183af9
41 files changed
+1704 -1941

No files matched your search

Whitespace-only changes.
Whitespace-only changes.
@@ -1,76 +0,0 @@
import json
from os import path
from pathlib import Path
from typing import Any, override
from flask import current_app
from sqlalchemy.orm import Session
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from buildin, the location is constants/recommended_apps.json
"""
builtin_data: dict[str, Any] | None = None
@override
def get_type(self) -> str:
return RecommendAppType.BUILDIN
@override
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
del session
result = self.fetch_recommended_apps_from_builtin(language)
return result
@override
def get_learn_dify_apps(self, language: str, *, session: Session):
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
return result
@override
def get_recommend_app_detail(self, app_id: str, *, session: Session):
del session
result = self.fetch_recommended_app_detail_from_builtin(app_id)
return result
@classmethod
def _get_builtin_data(cls):
"""
Get builtin data.
:return:
"""
if cls.builtin_data:
return cls.builtin_data
root_path = current_app.root_path
cls.builtin_data = json.loads(
Path(path.join(root_path, "constants", "recommended_apps.json")).read_text(encoding="utf-8")
)
return cls.builtin_data or {}
@classmethod
def fetch_recommended_apps_from_builtin(cls, language: str):
"""
Fetch recommended apps from builtin.
:param language: language
:return:
"""
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
return builtin_data.get("recommended_apps", {}).get(language, {})
@classmethod
def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict[str, Any] | None:
"""
Fetch recommended app detail from builtin.
:param app_id: App ID
:return:
"""
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
return builtin_data.get("app_details", {}).get(app_id)
@@ -1,49 +0,0 @@
"""Apply Redis-backed category ordering for DB-backed Explore apps."""
import json
import logging
from collections.abc import Collection
from typing import Any
from extensions.ext_redis import redis_client
logger = logging.getLogger(__name__)
EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
def _category_order_key(language: str) -> str:
return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}"
def get_explore_app_category_order(language: str) -> list[str]:
try:
raw_categories = redis_client.get(_category_order_key(language))
except Exception:
logger.exception("Failed to read explore app category order from Redis.")
return []
if not raw_categories:
return []
if isinstance(raw_categories, bytes):
raw_categories = raw_categories.decode("utf-8")
try:
categories: Any = json.loads(raw_categories)
except (TypeError, json.JSONDecodeError):
logger.warning("Invalid explore app category order payload for language %s.", language)
return []
if not isinstance(categories, list):
return []
return [category for category in categories if isinstance(category, str)]
def order_categories(categories: Collection[str], language: str) -> list[str]:
configured_order = get_explore_app_category_order(language)
if configured_order:
return configured_order
return sorted(categories)
Whitespace-only changes.
@@ -1,179 +0,0 @@
from typing import Any, NotRequired, TypedDict, override
from sqlalchemy import select
from sqlalchemy.orm import Session
from constants.languages import languages
from models.model import App, RecommendedApp
from services.app_dsl_service import AppDslService
from services.recommend_app.category_order import order_categories
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
class RecommendedAppItemDict(TypedDict):
id: str
app: App | None
app_id: str
description: Any
copyright: Any
privacy_policy: Any
custom_disclaimer: str
categories: list[str]
position: int
is_listed: bool
can_trial: NotRequired[bool]
class RecommendedAppsResultDict(TypedDict):
recommended_apps: list[RecommendedAppItemDict]
categories: list[str]
class RecommendedAppDetailDict(TypedDict):
id: str
name: str
icon: Any
icon_background: str | None
mode: str
export_data: str
class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from database
"""
@override
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
result = self.fetch_recommended_apps_from_db(language, session=session)
return result
@override
def get_learn_dify_apps(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
result = self.fetch_learn_dify_apps_from_db(language, session=session)
return result
@override
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
result = self.fetch_recommended_app_detail_from_db(app_id, session=session)
return result
@override
def get_type(self) -> str:
return RecommendAppType.DATABASE
@classmethod
def fetch_recommended_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
"""
Fetch recommended apps from db.
:param language: language
:return:
"""
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session)
if len(recommended_apps) == 0:
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session)
return cls._format_recommended_apps(recommended_apps, language, session=session)
@classmethod
def fetch_learn_dify_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
"""
Fetch listed recommended apps explicitly marked for the Learn Dify section.
:param language: language
:return:
"""
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session, is_learn_dify=True)
if len(recommended_apps) == 0 and language != languages[0]:
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session, is_learn_dify=True)
return cls._format_recommended_apps(recommended_apps, language, session=session)
@classmethod
def _fetch_listed_recommended_apps(
cls, language: str, *, session: Session, is_learn_dify: bool | None = None
) -> list[RecommendedApp]:
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
if is_learn_dify is not None:
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
return list(session.scalars(select(RecommendedApp).where(*filters)).all())
@classmethod
def _format_recommended_apps(
cls,
recommended_apps: list[RecommendedApp],
language: str,
*,
session: Session,
) -> RecommendedAppsResultDict:
"""
Serialize DB recommended app rows into the Explore list response shape.
:param recommended_apps: recommended app rows
:param language: language used for category ordering
:return:
"""
categories = set()
recommended_apps_result: list[RecommendedAppItemDict] = []
for recommended_app in recommended_apps:
app = session.get(App, recommended_app.app_id)
if not app or not app.is_public:
continue
site = app.site_with_session(session=session)
if not site:
continue
app_categories = recommended_app.categories or []
recommended_app_result: RecommendedAppItemDict = {
"id": recommended_app.id,
"app": app,
"app_id": recommended_app.app_id,
"description": site.description,
"copyright": site.copyright,
"privacy_policy": site.privacy_policy,
"custom_disclaimer": site.custom_disclaimer,
"categories": app_categories,
"position": recommended_app.position,
"is_listed": recommended_app.is_listed,
}
recommended_apps_result.append(recommended_app_result)
categories.update(app_categories)
return RecommendedAppsResultDict(
recommended_apps=recommended_apps_result,
categories=order_categories(categories, language),
)
@classmethod
def fetch_recommended_app_detail_from_db(cls, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
"""
Fetch recommended app detail from db.
:param app_id: App ID
:return:
"""
# is in public recommended list
recommended_app = session.scalar(
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id).limit(1)
)
if not recommended_app:
return None
# get app detail
app_model = session.get(App, app_id)
if not app_model or not app_model.is_public:
return None
return RecommendedAppDetailDict(
id=app_model.id,
name=app_model.name,
icon=app_model.icon,
icon_background=app_model.icon_background,
mode=app_model.mode,
export_data=AppDslService.export_dsl(app_model=app_model, session=session),
)
@@ -1,15 +0,0 @@
from typing import Any, Protocol
from sqlalchemy.orm import Session
class RecommendAppRetrievalBase(Protocol):
"""Interface for recommend app retrieval."""
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> Any: ...
def get_learn_dify_apps(self, language: str, *, session: Session) -> Any: ...
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> Any: ...
def get_type(self) -> str: ...
@@ -1,23 +0,0 @@
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval
class RecommendAppRetrievalFactory:
@staticmethod
def get_recommend_app_factory(mode: str) -> type[RecommendAppRetrievalBase]:
match mode:
case RecommendAppType.REMOTE:
return RemoteRecommendAppRetrieval
case RecommendAppType.DATABASE:
return DatabaseRecommendAppRetrieval
case RecommendAppType.BUILDIN:
return BuildInRecommendAppRetrieval
case _:
raise ValueError(f"invalid fetch recommended apps mode: {mode}")
@staticmethod
def get_buildin_recommend_app_retrieval():
return BuildInRecommendAppRetrieval
@@ -1,7 +0,0 @@
from enum import StrEnum
class RecommendAppType(StrEnum):
REMOTE = "remote"
BUILDIN = "builtin"
DATABASE = "db"
Whitespace-only changes.
@@ -1,173 +0,0 @@
import logging
import threading
from typing import Any, override
import httpx
from cachetools import TTLCache
from flask import has_request_context, request
from sqlalchemy.orm import Session
from configs import dify_config
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
logger = logging.getLogger(__name__)
_REMOTE_FETCH_CACHE_MAXSIZE = 64
_remote_fetch_cache: TTLCache[tuple[str, str], dict[str, Any]] | None = None
_remote_fetch_cache_ttl: int | None = None
_remote_fetch_cache_lock = threading.Lock()
def _current_origin_headers() -> dict[str, str]:
origin = request.headers.get("Origin") if has_request_context() else None
if origin:
return {"Origin": origin}
console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "")
if not isinstance(console_web_url, str) or not console_web_url:
return {}
return {"Origin": console_web_url}
def _remote_fetch_cache_key(url: str, headers: dict[str, str]) -> tuple[str, str]:
return url, headers.get("Origin", "")
def _hosted_fetch_cache_ttl() -> int:
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
if isinstance(ttl, int) and not isinstance(ttl, bool):
return ttl
return 600
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], dict[str, Any]] | None:
ttl = _hosted_fetch_cache_ttl()
if ttl <= 0:
return None
global _remote_fetch_cache, _remote_fetch_cache_ttl
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
with _remote_fetch_cache_lock:
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
_remote_fetch_cache_ttl = ttl
return _remote_fetch_cache
def clear_remote_fetch_cache() -> None:
"""Reset the in-memory remote fetch cache (used by tests)."""
global _remote_fetch_cache, _remote_fetch_cache_ttl
with _remote_fetch_cache_lock:
_remote_fetch_cache = None
_remote_fetch_cache_ttl = None
def _fetch_remote_payload(url: str) -> tuple[int, dict[str, Any] | None]:
headers = _current_origin_headers()
cache_key = _remote_fetch_cache_key(url, headers)
cache = _get_remote_fetch_cache()
if cache is not None:
with _remote_fetch_cache_lock:
cached = cache.get(cache_key)
if cached is not None:
return 200, cached
response = httpx.get(url, headers=headers, timeout=httpx.Timeout(10.0, connect=3.0))
status_code = response.status_code
if status_code != 200:
return status_code, None
result: dict[str, Any] = response.json()
if cache is not None:
with _remote_fetch_cache_lock:
cache[cache_key] = result
return status_code, result
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from dify official.
The remote `/apps` payload is already curated for display, including category order.
Keep the response order intact so Explore matches the template service.
"""
@override
def get_recommend_app_detail(self, app_id: str, *, session: Session):
del session
try:
result = self.fetch_recommended_app_detail_from_dify_official(app_id)
except Exception as e:
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", e)
result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin(app_id)
return result
@override
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
del session
try:
result = self.fetch_recommended_apps_from_dify_official(language)
except Exception as e:
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", e)
result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language)
return result
@override
def get_learn_dify_apps(self, language: str, *, session: Session):
try:
result = self.fetch_learn_dify_apps_from_dify_official(language)
except Exception as e:
logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e)
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
return result
@override
def get_type(self) -> str:
return RecommendAppType.REMOTE
@classmethod
def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict[str, Any] | None:
"""
Fetch recommended app detail from dify official.
:param app_id: App ID
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps/{app_id}"
status_code, data = _fetch_remote_payload(url)
if status_code != 200:
return None
return data
@classmethod
def fetch_recommended_apps_from_dify_official(cls, language: str):
"""
Fetch recommended apps from dify official.
:param language: language
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps?language={language}"
status_code, result = _fetch_remote_payload(url)
if status_code != 200:
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
return result
@classmethod
def fetch_learn_dify_apps_from_dify_official(cls, language: str):
"""
Fetch Learn Dify apps from dify official.
:param language: language
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps/learn-dify?language={language}"
status_code, result = _fetch_remote_payload(url)
if status_code != 200:
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
return result
@@ -0,0 +1,376 @@
"""Typed remote and built-in adapters for the recommended app catalog."""
import json
import logging
import threading
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import cast, override
import httpx
from cachetools import TTLCache
from configs import dify_config
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
RecommendedAppDetailRecord,
RecommendedAppInfoRecord,
RecommendedAppRecord,
)
logger = logging.getLogger(__name__)
_BUILTIN_FALLBACK_LANGUAGE = "en-US"
_BUILTIN_CATALOG_PATH = Path(__file__).resolve().parents[1] / "constants" / "recommended_apps.json"
_REMOTE_FETCH_CACHE_MAXSIZE = 64
_remote_fetch_cache: TTLCache[tuple[str, str], object] | None = None
_remote_fetch_cache_ttl: int | None = None
_remote_fetch_cache_lock = threading.Lock()
def _hosted_fetch_cache_ttl() -> int:
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
if isinstance(ttl, int) and not isinstance(ttl, bool):
return ttl
return 600
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], object] | None:
ttl = _hosted_fetch_cache_ttl()
if ttl <= 0:
return None
global _remote_fetch_cache, _remote_fetch_cache_ttl
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
with _remote_fetch_cache_lock:
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
_remote_fetch_cache_ttl = ttl
return _remote_fetch_cache
def clear_remote_fetch_cache() -> None:
"""Reset the in-memory remote fetch cache (used by tests)."""
global _remote_fetch_cache, _remote_fetch_cache_ttl
with _remote_fetch_cache_lock:
_remote_fetch_cache = None
_remote_fetch_cache_ttl = None
class _RecommendedAppSourceUnavailableError(Exception):
pass
class BuiltinRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
def __init__(self) -> None:
self._data: Mapping[str, object] | None = None
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
return _map_recommended_page(self._raw_page(language))
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
return _map_learn_dify_page(self._raw_learn_dify_page(language))
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
detail = self._raw_detail(app_id)
if detail is None:
return None
return _map_detail(_as_mapping(detail, field="recommended app detail"))
@override
def contains(self, app_id: str) -> bool:
return self._raw_detail(app_id) is not None
def _raw_page(self, language: str) -> Mapping[str, object]:
pages = _as_mapping(self._get_data().get("recommended_apps", {}), field="recommended_apps")
return _as_mapping(pages.get(language, {}), field="recommended app page")
def _raw_learn_dify_page(self, language: str) -> Mapping[str, object]:
apps = self._raw_learn_dify_apps(language)
if not apps and language != _BUILTIN_FALLBACK_LANGUAGE:
apps = self._raw_learn_dify_apps(_BUILTIN_FALLBACK_LANGUAGE)
return {"recommended_apps": apps}
def _raw_learn_dify_apps(self, language: str) -> tuple[object, ...]:
page = self._raw_page(language)
return tuple(
app
for app in _as_sequence(page.get("recommended_apps", ()), field="apps")
if _as_mapping(app, field="recommended app").get("is_learn_dify") is True
)
def _raw_detail(self, app_id: str) -> object | None:
details = _as_mapping(self._get_data().get("app_details", {}), field="app_details")
return details.get(app_id)
def _get_data(self) -> Mapping[str, object]:
if self._data is None:
loaded = json.loads(_BUILTIN_CATALOG_PATH.read_text(encoding="utf-8"))
self._data = _as_mapping(loaded, field="built-in recommended app catalog")
return self._data
class RemoteRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
result = self._fetch(lambda: self._fetch_page(language))
return _map_recommended_page(_as_mapping(result, field="recommended app page"))
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
result = self._fetch(lambda: self._fetch_learn_dify_page(language))
return _map_learn_dify_page(_as_mapping(result, field="Learn Dify app page"))
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
detail = self._fetch(lambda: self._fetch_detail(app_id))
if detail is None:
return None
return _map_detail(_as_mapping(detail, field="recommended app detail"))
@override
def contains(self, app_id: str) -> bool:
detail = self._fetch(lambda: self._fetch_detail(app_id))
return detail is not None
def _fetch_detail(self, app_id: str) -> object | None:
status_code, detail = self._get_payload(f"/apps/{app_id}")
if status_code != 200:
# Preserve the legacy detail contract: only request or decoding
# failures use the bundled fallback; HTTP responses are authoritative.
return None
return detail
def _fetch_page(self, language: str) -> object:
status_code, page = self._get_payload(f"/apps?language={language}")
if status_code != 200:
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
return page
def _fetch_learn_dify_page(self, language: str) -> object:
status_code, page = self._get_payload(f"/apps/learn-dify?language={language}")
if status_code != 200:
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
return page
@staticmethod
def _get_payload(path: str) -> tuple[int, object]:
origin = dify_config.CONSOLE_WEB_URL
url = f"{dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN}{path}"
headers = {"Origin": origin} if origin else {}
cache_key = (url, origin)
cache = _get_remote_fetch_cache()
if cache is not None:
with _remote_fetch_cache_lock:
cached = cache.get(cache_key)
if cached is not None:
return 200, cached
response = httpx.get(
url,
headers=headers,
timeout=httpx.Timeout(10.0, connect=3.0),
)
if response.status_code != 200:
return response.status_code, None
result = response.json()
if cache is not None:
with _remote_fetch_cache_lock:
cache[cache_key] = result
return response.status_code, result
@staticmethod
def _fetch[T](fetch: Callable[[], T]) -> T:
try:
return fetch()
except Exception as error:
raise _RecommendedAppSourceUnavailableError(str(error)) from error
class RecommendedAppCatalogRouter(RecommendedAppCatalogQuery):
def __init__(
self,
*,
remote: RecommendedAppCatalogQuery,
database: RecommendedAppCatalogQuery,
builtin: RecommendedAppCatalogQuery,
) -> None:
self._remote = remote
self._builtin = builtin
self._sources: dict[str, RecommendedAppCatalogQuery] = {
"remote": remote,
"db": database,
"builtin": builtin,
}
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
source = self._source()
if source is not self._remote:
page = source.list_recommended(language)
else:
try:
page = self._remote.list_recommended(language)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", error)
page = self._builtin.list_recommended(language)
if not page.recommended_apps:
return self._builtin.list_recommended(_BUILTIN_FALLBACK_LANGUAGE)
return page
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
source = self._source()
if source is not self._remote:
return source.list_learn_dify(language)
try:
return self._remote.list_learn_dify(language)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch learn dify apps from dify official failed: %s, switch to built-in.", error)
return self._builtin.list_learn_dify(language)
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
source = self._source()
if source is not self._remote:
return source.get_detail(app_id)
try:
return self._remote.get_detail(app_id)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
return self._builtin.get_detail(app_id)
@override
def contains(self, app_id: str) -> bool:
source = self._source()
if source is not self._remote:
return source.contains(app_id)
try:
return self._remote.contains(app_id)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
return self._builtin.contains(app_id)
def _source(self) -> RecommendedAppCatalogQuery:
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
try:
return self._sources[mode]
except KeyError:
raise ValueError(f"invalid fetch recommended apps mode: {mode}") from None
def _map_recommended_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
if not source.get("recommended_apps"):
return RecommendedAppCatalogPage(recommended_apps=(), categories=())
return _map_page(source)
def _map_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
return RecommendedAppCatalogPage(
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
categories=_as_string_tuple(source["categories"], field="categories"),
)
def _map_learn_dify_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
return RecommendedAppCatalogPage(
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
categories=(),
)
def _map_app(source: object) -> RecommendedAppRecord:
source = _as_mapping(source, field="recommended app")
app_id = source["app_id"]
if not isinstance(app_id, str):
raise TypeError("app_id must be a string")
app_source = source.get("app")
if app_source is not None:
app_source = _as_mapping(app_source, field="app")
return RecommendedAppRecord(
app=_map_app_info(app_source),
app_id=app_id,
description=cast(str | None, source.get("description")),
copyright=cast(str | None, source.get("copyright")),
privacy_policy=cast(str | None, source.get("privacy_policy")),
custom_disclaimer=cast(str | None, source.get("custom_disclaimer")),
categories=_as_string_tuple(source.get("categories", ()), field="categories"),
position=cast(int | None, source.get("position")),
is_listed=cast(bool | None, source.get("is_listed")),
)
def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoRecord | None:
if source is None:
return None
app_id = source["id"]
if not isinstance(app_id, str):
raise TypeError("app.id must be a string")
return RecommendedAppInfoRecord(
id=app_id,
name=cast(str | None, source.get("name")),
mode=_enum_string(source.get("mode"), field="app.mode"),
icon=cast(str | None, source.get("icon")),
icon_type=_enum_string(source.get("icon_type"), field="app.icon_type"),
icon_background=cast(str | None, source.get("icon_background")),
)
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
app_id = source["id"]
name = source["name"]
export_data = source["export_data"]
if not isinstance(app_id, str):
raise TypeError("id must be a string")
if not isinstance(name, str):
raise TypeError("name must be a string")
if not isinstance(export_data, str):
raise TypeError("export_data must be a string")
mode = _enum_string(source["mode"], field="mode")
if mode is None:
raise TypeError("mode must be a string or string enum")
return RecommendedAppDetailRecord(
id=app_id,
name=name,
icon=cast(str | None, source.get("icon")),
icon_background=cast(str | None, source.get("icon_background")),
mode=mode,
export_data=export_data,
)
def _as_mapping(value: object, *, field: str) -> Mapping[str, object]:
if not isinstance(value, Mapping):
raise TypeError(f"{field} must be a mapping")
return cast(Mapping[str, object], value)
def _as_sequence(value: object, *, field: str) -> Sequence[object]:
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
raise TypeError(f"{field} must be a sequence")
return cast(Sequence[object], value)
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
values = _as_sequence(value, field=field)
if not all(isinstance(item, str) for item in values):
raise TypeError(f"{field} must contain only strings")
return cast(tuple[str, ...], tuple(values))
def _enum_string(value: object, *, field: str) -> str | None:
if value is None:
return None
if isinstance(value, str):
return str(value)
raise TypeError(f"{field} must be a string or string enum")
@@ -1,192 +0,0 @@
"""Compatibility adapter for the existing recommended app retrieval stack."""
from collections.abc import Mapping, Sequence
from typing import cast, override
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from models.model import App
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
from services.recommended_app_query_service import (
RecommendedAppCatalogGateway,
RecommendedAppCatalogPage,
RecommendedAppDetailRecord,
RecommendedAppInfoRecord,
RecommendedAppRecord,
)
class LegacyRecommendedAppCatalogGateway(RecommendedAppCatalogGateway):
"""Map the mixed dict/ORM retrieval results to persistence-neutral records."""
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def is_recommended(self, app_id: str) -> bool:
retrieval = self._configured_retrieval()
with self._session_factory() as session:
return retrieval.get_recommend_app_detail(app_id, session=session) is not None
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
retrieval = self._configured_retrieval()
with self._session_factory() as session:
result = retrieval.get_recommended_apps_and_categories(language, session=session)
return self._map_recommended_page(result)
@override
def list_builtin(self, language: str) -> RecommendedAppCatalogPage:
retrieval = RecommendAppRetrievalFactory.get_buildin_recommend_app_retrieval()
return self._map_page(retrieval.fetch_recommended_apps_from_builtin(language))
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
retrieval = self._configured_retrieval()
with self._session_factory() as session:
result = retrieval.get_learn_dify_apps(language, session=session)
raw_apps = cast(Sequence[object], result["recommended_apps"])
return RecommendedAppCatalogPage(
recommended_apps=tuple(self._map_app(app) for app in raw_apps),
categories=(),
)
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
retrieval = self._configured_retrieval()
with self._session_factory() as session:
result = retrieval.get_recommend_app_detail(app_id, session=session)
if result is None:
return None
if not isinstance(result, Mapping):
raise TypeError("recommended app detail must be a mapping")
return self._map_detail(cast(Mapping[str, object], result))
@staticmethod
def _configured_retrieval():
retrieval_type = RecommendAppRetrievalFactory.get_recommend_app_factory(
dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
)
return retrieval_type()
@classmethod
def _map_page(cls, result: Mapping[str, object]) -> RecommendedAppCatalogPage:
raw_apps = cast(Sequence[object], result["recommended_apps"])
return RecommendedAppCatalogPage(
recommended_apps=tuple(cls._map_app(app) for app in raw_apps),
categories=cls._as_string_tuple(result["categories"], field="categories"),
)
@classmethod
def _map_recommended_page(cls, result: Mapping[str, object]) -> RecommendedAppCatalogPage:
if not result.get("recommended_apps"):
return RecommendedAppCatalogPage(recommended_apps=(), categories=())
return cls._map_page(result)
@classmethod
def _map_app(cls, source: object) -> RecommendedAppRecord:
if not isinstance(source, Mapping):
raise TypeError("recommended app must be a mapping")
source = cast(Mapping[str, object], source)
app_id = source["app_id"]
if not isinstance(app_id, str):
raise TypeError("app_id must be a string")
app_source = source.get("app")
if app_source is not None and not isinstance(app_source, (Mapping, App)):
raise TypeError("app must be a mapping or App")
return RecommendedAppRecord(
app=cls._map_app_info(cast(Mapping[str, object] | App | None, app_source)),
app_id=app_id,
description=cast(str | None, source.get("description")),
copyright=cast(str | None, source.get("copyright")),
privacy_policy=cast(str | None, source.get("privacy_policy")),
custom_disclaimer=cast(str | None, source.get("custom_disclaimer")),
categories=cls._as_string_tuple(source.get("categories", ()), field="categories"),
position=cast(int | None, source.get("position")),
is_listed=cast(bool | None, source.get("is_listed")),
)
@classmethod
def _map_app_info(cls, source: Mapping[str, object] | App | None) -> RecommendedAppInfoRecord | None:
if source is None:
return None
app_id: object
name: object
mode: object
icon: object
icon_type: object
icon_background: object
if isinstance(source, App):
app_id = source.id
name = source.name
mode = source.mode
icon = source.icon
icon_type = source.icon_type
icon_background = source.icon_background
else:
app_id = source["id"]
name = source.get("name")
mode = source.get("mode")
icon = source.get("icon")
icon_type = source.get("icon_type")
icon_background = source.get("icon_background")
if not isinstance(app_id, str):
raise TypeError("app.id must be a string")
return RecommendedAppInfoRecord(
id=app_id,
name=cast(str | None, name),
mode=cls._enum_string(mode, field="app.mode"),
icon=cast(str | None, icon),
icon_type=cls._enum_string(icon_type, field="app.icon_type"),
icon_background=cast(str | None, icon_background),
)
@classmethod
def _map_detail(cls, source: Mapping[str, object]) -> RecommendedAppDetailRecord:
app_id = source["id"]
name = source["name"]
export_data = source["export_data"]
if not isinstance(app_id, str):
raise TypeError("id must be a string")
if not isinstance(name, str):
raise TypeError("name must be a string")
if not isinstance(export_data, str):
raise TypeError("export_data must be a string")
mode = cls._enum_string(source["mode"], field="mode")
if mode is None:
raise TypeError("mode must be a string or string enum")
return RecommendedAppDetailRecord(
id=app_id,
name=name,
icon=cast(str | None, source.get("icon")),
icon_background=cast(str | None, source.get("icon_background")),
mode=mode,
export_data=export_data,
)
@staticmethod
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
raise TypeError(f"{field} must be a sequence of strings")
items: list[str] = []
for item in value:
if not isinstance(item, str):
raise TypeError(f"{field} must contain only strings")
items.append(item)
return tuple(items)
@staticmethod
def _enum_string(value: object, *, field: str) -> str | None:
if value is None:
return None
if isinstance(value, str):
return str(value)
raise TypeError(f"{field} must be a string or string enum")
+6 -10
View File
@@ -5,8 +5,6 @@ from typing import NamedTuple, Protocol
from constants.languages import languages
_BUILTIN_FALLBACK_LANGUAGE = "en-US"
class RecommendedAppInfoRecord(NamedTuple):
id: str
@@ -43,17 +41,17 @@ class RecommendedAppDetailRecord(NamedTuple):
export_data: str
class RecommendedAppCatalogGateway(Protocol):
def is_recommended(self, app_id: str) -> bool: ...
class RecommendedAppCatalogQuery(Protocol):
"""Read from the recommended-app catalog."""
def list_recommended(self, language: str) -> RecommendedAppCatalogPage: ...
def list_builtin(self, language: str) -> RecommendedAppCatalogPage: ...
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: ...
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: ...
def contains(self, app_id: str) -> bool: ...
class TrialAppQuery(Protocol):
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]: ...
@@ -99,7 +97,7 @@ class RecommendedAppQueryService:
def __init__(
self,
*,
catalog: RecommendedAppCatalogGateway,
catalog: RecommendedAppCatalogQuery,
trial_apps: TrialAppQuery,
trial_enabled: bool,
) -> None:
@@ -113,7 +111,7 @@ class RecommendedAppQueryService:
def is_previewable(self, app_id: str) -> bool:
if app_id in self._trial_apps.existing_ids((app_id,)):
return True
return self._catalog.is_recommended(app_id)
return self._catalog.contains(app_id)
def list_recommended(
self,
@@ -123,8 +121,6 @@ class RecommendedAppQueryService:
) -> RecommendedAppListResult:
language = self._resolve_language(requested_language, interface_language)
page = self._catalog.list_recommended(language)
if not page.recommended_apps:
page = self._catalog.list_builtin(_BUILTIN_FALLBACK_LANGUAGE)
return RecommendedAppListResult(
recommended_apps=self._with_trial_status(page.recommended_apps),