diff --git a/backend/artifacts/utils.py b/backend/artifacts/utils.py index 2bcb481e0f19756312e52e622ce3bbde032204c3..c12a1a749991176c60e83b06a721e3f878637bb9 100644 --- a/backend/artifacts/utils.py +++ b/backend/artifacts/utils.py @@ -15,7 +15,11 @@ import os import subprocess import yaml +import operator +from functools import reduce from django.db import connection +from django.db.models import Q, Case, When, Value, IntegerField,FloatField,F +from django.db.models.functions import Length from tasks.models import Task from artifacts.models import MCPServer from artifacts.serializers import PluginItemSerializer @@ -26,6 +30,18 @@ from utils.logger import init_log logger = init_log('run.log') +# 权重配置 +SEARCH_WEIGHTS = { + 'exact_match': 10.0, + 'phrase_match': 5.0, + 'name_field': 3.0, + 'position_bonus': 3.0, + 'length_factor': 2.0, + 'coverage': 1.5 +} + + + def clear_table(table_name): """清空指定数据库表并重置自增主键""" logger.info(f"Start to clear table '{table_name}'") @@ -149,3 +165,141 @@ def check_system_rpm_installed(package_name: str) -> bool: except Exception: return False +def calculate_weighted_relevance(queryset, search_value, weights=None): + """基于权重的相关性算法""" + if not search_value: + return queryset.annotate(relevance_score=Value(0.0, output_field=FloatField())) + + weights = weights or SEARCH_WEIGHTS + search_terms = [term.strip() for term in search_value.lower().split() if term.strip()] + full_search = search_value.lower().strip() + + annotations = {} + annotations.update(_build_basic_factors(full_search)) + annotations.update(_build_position_factor(full_search, search_terms)) + annotations.update(_build_complex_factors(search_terms)) + annotations['relevance_score'] = _build_relevance_score(weights) + + return queryset.annotate(**annotations) + +def _build_basic_factors(full_search): + """构建基础匹配因子""" + return { + 'exact_match_factor': Case( + When(name__iexact=full_search, then=Value(1.0)), + default=Value(0.0), output_field=FloatField() + ), + 'phrase_match_factor': Case( + When(name__icontains=full_search, then=Value(1.0)), + When(description__icontains=full_search, then=Value(0.7)), + default=Value(0.0), output_field=FloatField() + ), + 'length_factor': Case( + When(name__icontains=full_search, + then=Value(1.0) / (Length('name') / len(full_search))), + default=Value(0.0), output_field=FloatField() + ) + } + +def _build_position_factor(full_search, search_terms): + """构建位置匹配因子 """ + position_kwargs = { + f'name_startswith_{i}': Case( + When(name__istartswith=term, then=Value(0.8 / (i + 1))), + default=Value(0.0), output_field=FloatField() + ) for i, term in enumerate(search_terms) + } + + position_factor = Case( + When(name__istartswith=full_search, then=Value(1.0)), + When(name__icontains=full_search, then=Value(0.6)), + **position_kwargs, + default=Value(0.0), output_field=FloatField() + ) + + return {'position_factor': position_factor} + +def _build_complex_factors(search_terms): + """构建复杂匹配因子""" + if not search_terms: + return { + 'word_match_factor': Value(0.0, output_field=FloatField()), + 'coverage_factor': Value(0.0, output_field=FloatField()) + } + + # 构建单词匹配的Case列表 + word_cases = _build_word_cases(search_terms) + + total_score = sum(word_cases) + max_possible_score = len(search_terms) * 1.0 + word_match_factor = total_score / max_possible_score if max_possible_score > 0 else Value(0.0) + + coverage_cases = _build_coverage_cases(search_terms) + coverage_factor = sum(coverage_cases) / len(search_terms) + + return { + 'word_match_factor': word_match_factor, + 'coverage_factor': coverage_factor + } + +def _build_word_cases(search_terms): + """构建单词匹配案例""" + word_cases = [] + for term in search_terms: + case = Case( + When(name__icontains=term, then=Value(1.0)), + When(description__icontains=term, then=Value(0.5)), + default=Value(0.0), output_field=FloatField() + ) + word_cases.append(case) + return word_cases + +def _build_coverage_cases(search_terms): + """构建覆盖率案例""" + coverage_cases = [] + for term in search_terms: + case = Case( + When(Q(name__icontains=term) | Q(description__icontains=term), + then=Value(1.0)), + default=Value(0.0), output_field=FloatField() + ) + coverage_cases.append(case) + return coverage_cases + +def _build_relevance_score(weights): + """构建最终相关性得分""" + return ( + F('exact_match_factor') * weights['exact_match'] + + F('phrase_match_factor') * weights['phrase_match'] + + F('position_factor') * weights['position_bonus'] + + F('word_match_factor') * weights['name_field'] + + F('coverage_factor') * weights['coverage'] + + F('length_factor') * weights['length_factor'] + ) + +def process_search_with_relevance(queryset, search_value, sort='rec'): + """处理多关键词搜索""" + + if search_value: + # 构建搜索条件:任意词匹配名称或描述 + search_terms = [term.strip() for term in search_value.split() if term.strip()] + conditions = [] + + for term in search_terms: + conditions.append(Q(name__icontains=term) | Q(description__icontains=term)) + + # 使用OR连接所有条件 + if conditions: + search_filter = reduce(operator.or_, conditions) + queryset = queryset.filter(search_filter) + + # 计算相关性 + queryset = calculate_weighted_relevance(queryset, search_value) + else: + queryset = queryset.annotate(relevance_score=Value(0.0, output_field=FloatField())) + + # 排序 + if sort == 'new': + return queryset.order_by('-relevance_score', '-updated_at') if search_value else queryset.order_by('-updated_at') + else: + return queryset.order_by('-relevance_score', 'name') if search_value else queryset.order_by('name') \ No newline at end of file diff --git a/backend/artifacts/views.py b/backend/artifacts/views.py index a0bfb4b2392a63e6c96f371e41e01fac36ecbe77..65b912130dde2d00ab6ee3b074d71e9e82f858a1 100644 --- a/backend/artifacts/views.py +++ b/backend/artifacts/views.py @@ -28,7 +28,7 @@ from artifacts.serializers import ( PluginDetailSerializer, ) from artifacts.tasks.install_mcp_task import InstallMCPTask -from artifacts.utils import get_devstore_log +from artifacts.utils import get_devstore_log,process_search_with_relevance from utils.mcp_tools import manage_mcp_config from constants.choices import ArtifactTag from tasks.models import Task @@ -165,29 +165,49 @@ class ArtifactViewSet(viewsets.GenericViewSet): def list(self, request): - """获取插件和MCP服务列表 - """ - logger.info("==== API: [GET] /v1.0/artifacts/ ====") + """获取插件和MCP服务列表(支持搜索与排序)""" + logger.info(f"==== API: [GET] /v1.0/artifacts/ ====") + tag = request.query_params.get('tag') - oedp_queryset = OEDPPlugin.objects.all() - mcp_queryset = MCPServer.objects.all() - oedp_count = oedp_queryset.count() - mcp_count = mcp_queryset.count() + search_value = request.query_params.get('searchValue', '').strip() + sort = request.query_params.get('sort', 'rec') + + # 全量汇总(不随搜索变化,与原 list 保持一致) + oedp_count = OEDPPlugin.objects.count() + mcp_count = MCPServer.objects.count() + + # tag 校验(与原 list 保持一致的错误处理) if tag == ArtifactTag.OEDP: - queryset = oedp_queryset + queryset = OEDPPlugin.objects.all() elif tag == ArtifactTag.MCP: - queryset = mcp_queryset + queryset = MCPServer.objects.all() else: msg = 'The query parameter [tag] is missing, or the value of the query parameter [tag] is invalid.' logger.error(msg) return Response({'is_success': False, 'message': msg}, status=status.HTTP_400_BAD_REQUEST) - queryset = self.paginate_queryset(queryset) + + queryset = process_search_with_relevance(queryset, search_value, sort) + total_count = queryset.count() + queryset = self.paginate_queryset(queryset) serializer = ArtifactSerializer(queryset, many=True) response = self.get_paginated_response(serializer.data) - data = { 'oedp_count': oedp_count, 'mcp_count': mcp_count } - data.update(response.data) - msg = "Get list information successfully." - response.data = { 'is_success': True, 'message': msg, 'data': data } + + data = { + 'oedp_count': oedp_count, + 'mcp_count': mcp_count + } + data.update(response.data) + if search_value: + data['search_keyword'] = search_value + data['search_count'] = total_count + msg = f'Search with keyword "{search_value}", found {total_count} results.' + else: + msg = "Get list information successfully." + response.data = { + 'is_success': True, + 'message': msg, + 'data': data + } logger.debug(msg) return response diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f428c62365b690a43d8cc06a9681396f282e691f..790ecb29c628b23c1222ad824e632730a51ab3c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,7 +33,7 @@ "@vue/tsconfig": "^0.7.0", "concurrently": "^9.2.0", "cross-env": "^7.0.3", - "electron": "^37.3.1", + "electron": "^37.4.0", "electron-builder": "^26.0.12", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", @@ -4704,9 +4704,9 @@ } }, "node_modules/electron": { - "version": "37.3.1", - "resolved": "https://registry.npmmirror.com/electron/-/electron-37.3.1.tgz", - "integrity": "sha512-7DhktRLqhe6OJh/Bo75bTI0puUYEmIwSzMinocgO63mx3MVjtIn2tYMzLmAleNIlud2htkjpsMG2zT4PiTCloA==", + "version": "37.4.0", + "resolved": "https://registry.npmmirror.com/electron/-/electron-37.4.0.tgz", + "integrity": "sha512-HhsSdWowE5ODOeWNc/323Ug2C52mq/TqNBG+4uMeOA3G2dMXNc/nfyi0RYu1rJEgiaJLEjtHveeZZaYRYFsFCQ==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 0b4d5ba0c152e8af3c7826b0affc519dc0397564..c1076c051acd456ee3685cbeae20b5a9f2e709ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,7 +73,7 @@ "@vue/tsconfig": "^0.7.0", "concurrently": "^9.2.0", "cross-env": "^7.0.3", - "electron": "^37.3.1", + "electron": "^37.4.0", "electron-builder": "^26.0.12", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index df8de14a26ad5d909b378516dd1d8630d3347ce4..a07e4e98a08688f90becb8a647a14e1aa6253bcb 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -60,12 +60,12 @@