diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 7483207..d91b870 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -418,6 +418,15 @@ func trimRecommendTerms(recommends []string, offset int) []string { seen := make(map[string]struct{}) for _, recommend := range recommends { + if recommend == "." { + if _, exists := seen[recommend]; exists { + continue + } + seen[recommend] = struct{}{} + resultRecommends = append(resultRecommends, recommend) + continue + } + recommendRunes := []rune(recommend) if offset > len(recommendRunes) { continue @@ -530,7 +539,7 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str searchInput := inputSlice[len(inputSlice)-1] matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) if len(matchedMembers) == 0 { - return []string{}, 0, nil + return fallbackConfigGroupByInput(ctx, rdb, recommendLenType, inputSlice, members) } prefixTokens := inputSlice[:len(inputSlice)-1] @@ -542,6 +551,16 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil } +func fallbackConfigGroupByInput(ctx context.Context, rdb *redis.Client, recommendLenType string, inputSlice []string, members []string) ([]string, int, error) { + recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, members) + recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + + return recommends, recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil +} + func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { members, err := rdb.SMembers(ctx, allSetKey).Result() if err != nil && err != redis.Nil { @@ -551,7 +570,7 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin searchInput := inputSlice[len(inputSlice)-1] matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) if len(matchedMembers) == 0 { - return []string{}, 0, nil + return fallbackMeasGroupByInput(ctx, rdb, recommendLenType, inputSlice) } recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) @@ -563,6 +582,27 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil } +func fallbackMeasGroupByInput(ctx context.Context, rdb *redis.Client, recommendLenType string, inputSlice []string) ([]string, int, error) { + compTag, _, ok := recommendGroupTokens(inputSlice) + if !ok { + return []string{}, 0, nil + } + + queryKey := getSpecificKeyByLength(constants.MeasTagRecommendHierarchyType, compTag) + members, err := rdb.SMembers(ctx, queryKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + + recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, members) + recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + + return recommends, recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil +} + func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) { for len([]rune(searchInput)) != 0 { filtered := make([]string, 0, len(members)) @@ -1060,7 +1100,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co if keyExists { var QueryData []string - if hierarchy == constants.MaxIdentifyHierarchy || (hierarchy == constants.IdentifyHierarchy && redisSetKey == constants.RedisAllMeasTagSetKey) { + if hierarchy == constants.MeasTagRecommendHierarchyType { QueryData = []string{""} } else { QueryData = []string{"."} @@ -1148,6 +1188,10 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) } if len(results) == 0 { + if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { + return []string{"."}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + } + // 如果没有结果,退一步(删除最后一个字符)并继续循环 // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 searchInput = trimLastRune(searchInput) @@ -1187,7 +1231,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } if termHierarchyLen == compareHierarchyLen && termPrefix == comparePrefix && strings.HasPrefix(termLastPart, searchInput) { - exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, memberComparePrefix, termLastPart) + exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, termLastPart) if err != nil || !exists { logger.Info(ctx, "fuzzy recommend term not found in specific redis set", "hierarchy", hierarchy, @@ -1201,6 +1245,11 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, continue } + if termLastPart == searchInput { + recommends = append(recommends, fuzzyExactContinuationValue(hierarchy)) + continue + } + recommend := result.Term if hierarchy == constants.MeasTagRecommendHierarchyType && configToken != "" && termPrefix == memberComparePrefix { recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".") @@ -1209,16 +1258,42 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } } if len(recommends) == 0 { + if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { + return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + } + searchInput = trimLastRune(searchInput) searchInputLen = len([]rune(searchInput)) continue } - return recommends, fuzzyRecommendOffset(searchPrefix, searchInput), nil + offset := fuzzyRecommendOffset(searchPrefix, searchInput) + return recommends, offset, nil } return []string{}, 0, nil } +func fuzzyExactContinuationExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, memberComparePrefix string, searchInput string, fuzzyInput string) bool { + exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput) + if err != nil { + logger.Info(ctx, "fuzzy exact continuation check failed", + "hierarchy", hierarchy, + "recommend_len_type", recommendLenType, + "query_key", fuzzyInput, + "search_input", searchInput, + "error", err, + ) + } + return exists +} + +func fuzzyExactContinuationValue(hierarchy constants.RecommendHierarchyType) string { + if hierarchy == constants.MeasTagRecommendHierarchyType { + return "" + } + return "." +} + // fuzzyRecommendOffset 返回模糊命中的裁剪位置, 保留完整输入前缀参与 offset。 func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { if searchPrefix == "" { @@ -1228,8 +1303,8 @@ func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { } // fuzzyRecommendMemberExists 校验模糊候选末级 token 是否存在于对应 Redis set。 -func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, comparePrefix string, termLastPart string) (bool, error) { - setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, comparePrefix) +func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string, termLastPart string) (bool, error) { + setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, recommendLenType, comparePrefix) if !ok { return true, nil } @@ -1237,7 +1312,7 @@ func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarch } // fuzzyRecommendMemberSetKey 返回模糊候选校验时应使用的 Redis set key。 -func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comparePrefix string) (string, bool) { +func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string) (string, bool) { switch hierarchy { case constants.GridRecommendHierarchyType: return constants.RedisAllGridSetKey, true @@ -1246,6 +1321,9 @@ func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comp case constants.StationRecommendHierarchyType: return fmt.Sprintf(constants.RedisSpecZoneStationSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" case constants.CompNSPathRecommendHierarchyType: + if recommendLenType == constants.IsLocalRecommendLength { + return constants.RedisAllCompNSPathSetKey, true + } return fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" case constants.CompTagRecommendHierarchyType: return fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index a522789..c386a10 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -46,6 +46,78 @@ func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) { } } +func TestFuzzyRecommendMemberSetKeyUsesLocalNSPathSet(t *testing.T) { + setKey, ok := fuzzyRecommendMemberSetKey(constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, "") + if !ok { + t.Fatalf("expected local nspath fuzzy member check to use a redis set") + } + if setKey != constants.RedisAllCompNSPathSetKey { + t.Fatalf("expected set key %q, got %q", constants.RedisAllCompNSPathSetKey, setKey) + } +} + +func TestNormalizeRecommendResultsKeepsFuzzyExactContinuation(t *testing.T) { + tests := []struct { + name string + input string + recommendType constants.RecommendHierarchyType + offset int + }{ + { + name: "local nspath typo completes current level", + input: "110kV_TV-demoProjectx", + recommendType: constants.CompNSPathRecommendHierarchyType, + offset: len([]rune("110kV_TV-demoProject")), + }, + { + name: "grid typo completes current level", + input: "grid000x", + recommendType: constants.GridRecommendHierarchyType, + offset: len([]rune("grid000")), + }, + { + name: "zone typo completes current level", + input: "grid000.zone000x", + recommendType: constants.ZoneRecommendHierarchyType, + offset: len([]rune("grid000.zone000")), + }, + { + name: "station typo completes current level", + input: "grid000.zone000.station000x", + recommendType: constants.StationRecommendHierarchyType, + offset: len([]rune("grid000.zone000.station000")), + }, + { + name: "full nspath typo completes current level", + input: "grid000.zone000.station000.110kV_TV-demoProjectx", + recommendType: constants.CompNSPathRecommendHierarchyType, + offset: len([]rune("grid000.zone000.station000.110kV_TV-demoProject")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := map[string]SearchResult{ + tt.recommendType.String(): { + RecommendType: tt.recommendType, + QueryDatas: []string{"."}, + IsFuzzy: true, + Offset: tt.offset, + }, + } + + got := normalizeRecommendResults(tt.input, results) + result := got[tt.recommendType.String()] + if result.Offset != tt.offset { + t.Fatalf("expected offset %d, got %d", tt.offset, result.Offset) + } + if !reflect.DeepEqual(result.QueryDatas, []string{"."}) { + t.Fatalf("expected fuzzy exact continuation '.', got %#v", result.QueryDatas) + } + }) + } +} + func TestNormalizeRecommendResultsUsesInputLengthForExactCompletion(t *testing.T) { tests := []struct { name string @@ -350,6 +422,78 @@ func TestNormalizeRecommendResultsKeepsFullAndLocalBayMeasurementSuffixesConsist } } +func TestNormalizeRecommendResultsKeepsConfigMismatchFallbackConsistentWithEmptyInput(t *testing.T) { + prefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV" + members := []string{"bay", "base_extend", "model", "component"} + groupResults := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(prefix+".", "."), members) + offset := recommendPrefixOffset(strings.Split(prefix, ".")) + + emptyInputResults := map[string]SearchResult{ + constants.ConfigRecommendHierarchyType.String(): { + RecommendType: constants.ConfigRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: false, + }, + } + mismatchFallbackResults := map[string]SearchResult{ + constants.ConfigRecommendHierarchyType.String(): { + RecommendType: constants.ConfigRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: true, + IsFallback: true, + Offset: offset, + }, + } + + emptyGot := normalizeRecommendResults(prefix+".", emptyInputResults) + mismatchGot := normalizeRecommendResults(prefix+".x", mismatchFallbackResults) + emptyResult := emptyGot[constants.ConfigRecommendHierarchyType.String()] + mismatchResult := mismatchGot[constants.ConfigRecommendHierarchyType.String()] + + if mismatchResult.Offset != emptyResult.Offset { + t.Fatalf("expected config mismatch fallback offset %d, got %d", emptyResult.Offset, mismatchResult.Offset) + } + if !reflect.DeepEqual(mismatchResult.QueryDatas, emptyResult.QueryDatas) { + t.Fatalf("expected config mismatch fallback recommends to match empty input, empty=%#v mismatch=%#v", emptyResult.QueryDatas, mismatchResult.QueryDatas) + } +} + +func TestNormalizeRecommendResultsKeepsMeasurementMismatchFallbackConsistentWithEmptyInput(t *testing.T) { + prefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay" + members := []string{"IA_rms_CTA-testProject1", "IB_rms_CTA-testProject1", "IC_rms_CTA-testProject1"} + groupResults := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(prefix+".", "."), members) + offset := recommendPrefixOffset(strings.Split(prefix, ".")) + + emptyInputResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: false, + }, + } + mismatchFallbackResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: true, + IsFallback: true, + Offset: offset, + }, + } + + emptyGot := normalizeRecommendResults(prefix+".", emptyInputResults) + mismatchGot := normalizeRecommendResults(prefix+".x", mismatchFallbackResults) + emptyResult := emptyGot[constants.MeasTagRecommendHierarchyType.String()] + mismatchResult := mismatchGot[constants.MeasTagRecommendHierarchyType.String()] + + if mismatchResult.Offset != emptyResult.Offset { + t.Fatalf("expected measurement mismatch fallback offset %d, got %d", emptyResult.Offset, mismatchResult.Offset) + } + if !reflect.DeepEqual(mismatchResult.QueryDatas, emptyResult.QueryDatas) { + t.Fatalf("expected measurement mismatch fallback recommends to match empty input, empty=%#v mismatch=%#v", emptyResult.QueryDatas, mismatchResult.QueryDatas) + } +} + func TestRecommendGroupTokens(t *testing.T) { tests := []struct { name string