fix: correct measurement recommendation fuzzy matching and hierarchy fallback

- Fix component tag suggestion writes where full paths were overwritten by local paths
- Add exact token checks for trailing-dot inputs to avoid trimming valid hierarchy levels
- Improve config/meas fallback by trimming the input prefix before filtering candidates, avoiding full-level fallback output
- Use hash-based validation for config and measurement recommendation ownership instead of long-prefix autocomplete exact queries
This commit is contained in:
douxu 2026-07-10 11:02:56 +08:00
parent 6f78d8e341
commit 578f805b57
4 changed files with 627 additions and 61 deletions

View File

@ -22,6 +22,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
compTagToFullPath := make(map[string]string) compTagToFullPath := make(map[string]string)
isLocalCompTagToFullPath := make(map[string]string) isLocalCompTagToFullPath := make(map[string]string)
var allErrs []error
zoneToGridPath := make(map[string]string) zoneToGridPath := make(map[string]string)
for gridTag, zoneTags := range measSet.GridToZoneTags { for gridTag, zoneTags := range measSet.GridToZoneTags {
@ -62,12 +63,21 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
pipe.SAdd(ctx, key, members) pipe.SAdd(ctx, key, members)
} }
} }
safeAddTerms := func(sug []redisearch.Suggestion) {
if len(sug) == 0 {
return
}
if err := ac.AddTerms(sug...); err != nil {
logger.Error(ctx, "add measurement group recommend suggestions failed", "error", err)
allErrs = append(allErrs, err)
}
}
safeSAdd(constants.RedisAllGridSetKey, measSet.AllGridTags) safeSAdd(constants.RedisAllGridSetKey, measSet.AllGridTags)
gridSug := util.MapSlice(measSet.AllGridTags, func(gridTag string) redisearch.Suggestion { gridSug := util.MapSlice(measSet.AllGridTags, func(gridTag string) redisearch.Suggestion {
return redisearch.Suggestion{Term: gridTag, Score: constants.DefaultScore} return redisearch.Suggestion{Term: gridTag, Score: constants.DefaultScore}
}) })
ac.AddTerms(gridSug...) safeAddTerms(gridSug)
safeSAdd(constants.RedisAllZoneSetKey, measSet.AllZoneTags) safeSAdd(constants.RedisAllZoneSetKey, measSet.AllZoneTags)
safeSAdd(constants.RedisAllStationSetKey, measSet.AllStationTags) safeSAdd(constants.RedisAllStationSetKey, measSet.AllStationTags)
@ -83,7 +93,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
return redisearch.Suggestion{Term: fmt.Sprintf("%s.%s", gridTag, zoneTag), Score: constants.DefaultScore} return redisearch.Suggestion{Term: fmt.Sprintf("%s.%s", gridTag, zoneTag), Score: constants.DefaultScore}
}) })
safeSAdd(fmt.Sprintf(constants.RedisSpecGridZoneSetKey, gridTag), zoneTags) safeSAdd(fmt.Sprintf(constants.RedisSpecGridZoneSetKey, gridTag), zoneTags)
ac.AddTerms(sug...) safeAddTerms(sug)
} }
// building the zone -> stations hierarchy // building the zone -> stations hierarchy
@ -101,7 +111,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
}) })
safeSAdd(fmt.Sprintf(constants.RedisSpecZoneStationSetKey, zoneTag), stationTags) safeSAdd(fmt.Sprintf(constants.RedisSpecZoneStationSetKey, zoneTag), stationTags)
ac.AddTerms(sug...) safeAddTerms(sug)
} }
// building the station -> component nspaths hierarchy // building the station -> component nspaths hierarchy
@ -122,7 +132,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
sug = append(sug, redisearch.Suggestion{Term: nsPath, Score: constants.DefaultScore}) sug = append(sug, redisearch.Suggestion{Term: nsPath, Score: constants.DefaultScore})
} }
safeSAdd(fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, stationTag), compNSPaths) safeSAdd(fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, stationTag), compNSPaths)
ac.AddTerms(sug...) safeAddTerms(sug)
} }
// building the component nspath -> component tags hierarchy // building the component nspath -> component tags hierarchy
@ -136,20 +146,17 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
} }
for _, compTag := range compTags { for _, compTag := range compTags {
fullPath := fmt.Sprintf("%s.%s.%s", parentPath, compNSPath, compTag) fullTerm, localTerm := compTagSuggestionTerms(parentPath, compNSPath, compTag)
compTagToFullPath[compTag] = fullPath compTagToFullPath[compTag] = fullTerm
fullPath = fmt.Sprintf("%s.%s", compNSPath, compTag) isLocalCompTagToFullPath[compTag] = localTerm
isLocalCompTagToFullPath[compTag] = fullPath
// add redis fuzzy search suggestion for token1-token7 type // add redis fuzzy search suggestion for token1-token7 type
term := fullPath sug = append(sug, redisearch.Suggestion{Term: fullTerm, Score: constants.DefaultScore})
sug = append(sug, redisearch.Suggestion{Term: term, Score: constants.DefaultScore})
// add redis fuzzy search suggestion for token4-token7 type // add redis fuzzy search suggestion for token4-token7 type
term = fmt.Sprintf("%s.%s", compNSPath, compTag) sug = append(sug, redisearch.Suggestion{Term: localTerm, Score: constants.DefaultScore})
sug = append(sug, redisearch.Suggestion{Term: term, Score: constants.DefaultScore})
} }
safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, compNSPath), compTags) safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, compNSPath), compTags)
ac.AddTerms(sug...) safeAddTerms(sug)
} }
// building the component tag -> measurement tags hierarchy // building the component tag -> measurement tags hierarchy
@ -187,7 +194,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
if len(measTags) > 0 { if len(measTags) > 0 {
pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...) pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...)
} }
ac.AddTerms(sug...) safeAddTerms(sug)
} }
// building the component nspath -> measurement tags hierarchy for token4-token7 shorthand // building the component nspath -> measurement tags hierarchy for token4-token7 shorthand
@ -200,10 +207,9 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
}) })
} }
safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, compNSPath), measTags) safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, compNSPath), measTags)
ac.AddTerms(sug...) safeAddTerms(sug)
} }
var allErrs []error
cmders, execErr := pipe.Exec(ctx) cmders, execErr := pipe.Exec(ctx)
if execErr != nil { if execErr != nil {
logger.Error(ctx, "pipeline execution failed", "error", execErr) logger.Error(ctx, "pipeline execution failed", "error", execErr)
@ -227,3 +233,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
return compTagToFullPath, isLocalCompTagToFullPath, nil return compTagToFullPath, isLocalCompTagToFullPath, nil
} }
func compTagSuggestionTerms(parentPath string, compNSPath string, compTag string) (string, string) {
return fmt.Sprintf("%s.%s.%s", parentPath, compNSPath, compTag), fmt.Sprintf("%s.%s", compNSPath, compTag)
}

View File

@ -0,0 +1,21 @@
package model
import "testing"
func TestCompTagSuggestionTermsKeepFullAndLocalPaths(t *testing.T) {
parentPath := "grid000.zone000.station000"
compNSPath := "110kV_TV-demoProject"
compTag := "cable_22-testProject1110kV_TV"
fullTerm, localTerm := compTagSuggestionTerms(parentPath, compNSPath, compTag)
wantFullTerm := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV"
if fullTerm != wantFullTerm {
t.Fatalf("expected full suggestion term %q, got %q", wantFullTerm, fullTerm)
}
wantLocalTerm := "110kV_TV-demoProject.cable_22-testProject1110kV_TV"
if localTerm != wantLocalTerm {
t.Fatalf("expected local suggestion term %q, got %q", wantLocalTerm, localTerm)
}
}

View File

@ -171,9 +171,9 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
return finalizeResults("", results) return finalizeResults("", results)
} }
inputSlice := strings.Split(input, ".") searchInput := normalizeTrailingDotSearchInput(ctx, rdb, input)
inputSlice := strings.Split(searchInput, ".")
inputSliceLen := len(inputSlice) inputSliceLen := len(inputSlice)
switch inputSliceLen { switch inputSliceLen {
case 1: case 1:
searchInput := inputSlice[0] searchInput := inputSlice[0]
@ -185,7 +185,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
return levelOneRedisSearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, searchInput, constants.RedisAllCompNSPathSetKey) return levelOneRedisSearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, searchInput, constants.RedisAllCompNSPathSetKey)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
case 2: case 2:
results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false,
func() SearchResult { func() SearchResult {
@ -198,7 +198,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
return handleNSPathMeasSearch(ctx, rdb, inputSlice) return handleNSPathMeasSearch(ctx, rdb, inputSlice)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
case 3: case 3:
results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false,
func() SearchResult { func() SearchResult {
@ -208,7 +208,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
case 4: case 4:
results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false,
func() SearchResult { func() SearchResult {
@ -218,28 +218,28 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
case 5: case 5:
results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false,
func() SearchResult { func() SearchResult {
return handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
case 6: case 6:
results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false,
func() SearchResult { func() SearchResult {
return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
case 7: case 7:
results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false,
func() SearchResult { func() SearchResult {
return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice)
}, },
) )
return finalizeResults(input, results) return finalizeResults(searchInput, results)
default: default:
logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen) logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen)
return nil return nil
@ -250,6 +250,92 @@ func shouldFallbackToInitialRecommend(input string) bool {
return strings.HasPrefix(input, ".") return strings.HasPrefix(input, ".")
} }
// normalizeTrailingDotSearchInput 忽略误输入的尾随点号, 让前一段 token 继续参与模糊搜索。
func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, input string) string {
if input == "" || !strings.HasSuffix(input, ".") || strings.HasSuffix(input, "..") {
return input
}
trimmedInput := strings.TrimSuffix(input, ".")
if trimmedInput == "" {
return input
}
checks := exactMatchChecksForInput(strings.Split(trimmedInput, "."))
if len(checks) == 0 {
return input
}
for _, check := range checks {
exists, err := rdb.SIsMember(ctx, check.setKey, check.member).Result()
if err != nil {
logger.Warn(ctx, "check trailing dot input exact match failed", "input", input, "key", check.setKey, "member", check.member, "error", err)
return input
}
if exists {
return input
}
}
return trimmedInput
}
type exactMatchCheck struct {
setKey string
member string
}
func exactMatchChecksForInput(inputSlice []string) []exactMatchCheck {
inputSliceLen := len(inputSlice)
if inputSliceLen == 0 {
return nil
}
lastToken := inputSlice[inputSliceLen-1]
if lastToken == "" {
return nil
}
switch inputSliceLen {
case 1:
return []exactMatchCheck{
{setKey: constants.RedisAllGridSetKey, member: lastToken},
{setKey: constants.RedisAllCompNSPathSetKey, member: lastToken},
}
case 2:
return []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, inputSlice[0]), member: lastToken},
{setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, inputSlice[0]), member: lastToken},
{setKey: constants.RedisAllCompTagSetKey, member: lastToken},
{setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, inputSlice[0]), member: lastToken},
}
case 3:
return []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecZoneStationSetKey, inputSlice[1]), member: lastToken},
{setKey: constants.RedisAllConfigSetKey, member: lastToken},
}
case 4:
return []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, inputSlice[2]), member: lastToken},
{setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[1]), member: lastToken},
}
case 5:
return []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, inputSlice[3]), member: lastToken},
{setKey: constants.RedisAllCompTagSetKey, member: lastToken},
}
case 6:
return []exactMatchCheck{
{setKey: constants.RedisAllConfigSetKey, member: lastToken},
}
case 7:
return []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[4]), member: lastToken},
}
default:
return nil
}
}
// suppressFallbackResultsWhenConcreteHitExists 有明确命中时丢弃兜底结果, 避免返回过宽推荐。 // suppressFallbackResultsWhenConcreteHitExists 有明确命中时丢弃兜底结果, 避免返回过宽推荐。
func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult { func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult {
hasConcreteHit := false hasConcreteHit := false
@ -396,30 +482,101 @@ func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy c
} }
// fallbackCurrentLevelByInput 兜底时先校验上下级关系, 避免错误前缀返回全层级数据。 // fallbackCurrentLevelByInput 兜底时先校验上下级关系, 避免错误前缀返回全层级数据。
func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, inputSlice []string) ([]string, error) { func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, inputSlice []string) ([]string, int, error) {
allSetKey, ok := getAllSetKeyByHierarchy(hierarchy) allSetKey, ok := getAllSetKeyByHierarchy(hierarchy)
if !ok { if !ok {
return nil, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) return nil, 0, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy)
}
if hierarchy == constants.ConfigRecommendHierarchyType {
return fallbackConfigByInput(ctx, rdb, allSetKey, recommendLenType, inputSlice)
}
if hierarchy == constants.MeasTagRecommendHierarchyType {
return fallbackMeasByInput(ctx, rdb, allSetKey, recommendLenType, inputSlice)
} }
specificSetKey, ok := fallbackSpecificSetKey(hierarchy, inputSlice) specificSetKey, ok := fallbackSpecificSetKey(hierarchy, inputSlice)
if !ok { if !ok {
return fallbackAllCurrentLevel(ctx, rdb, hierarchy) members, err := fallbackAllCurrentLevel(ctx, rdb, hierarchy)
return members, 0, err
} }
count, err := rdb.SInterCard(ctx, 1, allSetKey, specificSetKey).Result() count, err := rdb.SInterCard(ctx, 1, allSetKey, specificSetKey).Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
return nil, err return nil, 0, err
} }
if count == 0 { if count == 0 {
return []string{}, nil return []string{}, 0, nil
} }
members, err := rdb.SMembers(ctx, specificSetKey).Result() members, err := rdb.SMembers(ctx, specificSetKey).Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
return nil, err return nil, 0, err
} }
return members, nil return combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members), recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil
}
func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) {
if len(inputSlice) < 2 {
members, err := fallbackAllCurrentLevel(ctx, rdb, constants.ConfigRecommendHierarchyType)
return members, 0, err
}
members, err := rdb.SMembers(ctx, allSetKey).Result()
if err != nil && err != redis.Nil {
return nil, 0, err
}
searchInput := inputSlice[len(inputSlice)-1]
matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput)
if len(matchedMembers) == 0 {
return []string{}, 0, nil
}
prefixTokens := inputSlice[:len(inputSlice)-1]
recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers)
recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends)
if len(recommends) == 0 {
return []string{}, 0, nil
}
return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), 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 {
return nil, 0, err
}
searchInput := inputSlice[len(inputSlice)-1]
matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput)
if len(matchedMembers) == 0 {
return []string{}, 0, nil
}
recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers)
recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends)
if len(recommends) == 0 {
return []string{}, 0, nil
}
return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil
}
func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) {
for len([]rune(searchInput)) != 0 {
filtered := make([]string, 0, len(members))
for _, member := range members {
if strings.HasPrefix(member, searchInput) {
filtered = append(filtered, member)
}
}
if len(filtered) > 0 {
return filtered, searchInput
}
searchInput = trimLastRune(searchInput)
}
return []string{}, ""
} }
// fallbackSpecificSetKey 根据输入切片推导 fallback 应该校验的上一层级 specific set。 // fallbackSpecificSetKey 根据输入切片推导 fallback 应该校验的上一层级 specific set。
@ -591,6 +748,42 @@ func confirmMeasRecommendResultsByHash(ctx context.Context, rdb *redis.Client, i
return confirmedResults return confirmedResults
} }
func confirmConfigRecommendResultsByHash(ctx context.Context, rdb *redis.Client, inputSlice []string, members []string, recommends []string) []string {
compTag, ok := configRecommendCompTag(inputSlice)
if !ok {
return []string{}
}
confirmedResults := make([]string, 0, len(recommends))
for index, recommend := range recommends {
if index >= len(members) {
break
}
hashKey := recommendGroupKey(compTag, members[index])
exists, err := rdb.Exists(ctx, hashKey).Result()
if err != nil {
logger.Error(ctx, "config recommend hash confirmation failed", "hash_key", hashKey, "error", err)
continue
}
if exists == 0 {
continue
}
confirmedResults = append(confirmedResults, recommend)
}
return confirmedResults
}
func configRecommendCompTag(inputSlice []string) (string, bool) {
configTokenIndex := len(inputSlice) - 1
compTagIndex := configTokenIndex - 1
if compTagIndex < 0 || compTagIndex >= len(inputSlice) {
return "", false
}
compTag := inputSlice[compTagIndex]
return compTag, compTag != ""
}
func recommendGroupTokens(inputSlice []string) (string, string, bool) { func recommendGroupTokens(inputSlice []string) (string, string, bool) {
searchInputIndex := len(inputSlice) - 1 searchInputIndex := len(inputSlice) - 1
compTagIndex := searchInputIndex - 2 compTagIndex := searchInputIndex - 2
@ -844,26 +1037,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
recommandResults = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults) recommandResults = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults)
} }
if hierarchy == constants.ConfigRecommendHierarchyType { if hierarchy == constants.ConfigRecommendHierarchyType {
// check the relevance between the config hierarchy and measurement hierarchy output and the request input, i.e., use FT.SUGGET search_suggestions_dict "recommandResult" max 1 for an exact query to check if the result exists recommandResults = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults)
secondConfirmResults := make([]string, 0, len(recommandResults))
for _, res := range recommandResults {
results, err := ac.SuggestOpts(res, redisearch.SuggestOptions{
Num: 1,
Fuzzy: false,
WithScores: false,
WithPayloads: false,
})
if err != nil {
logger.Error(ctx, "config hierarchy query key second confirmation failed", "query_key", res, "error", err)
continue
}
if len(results) == 0 {
continue
}
secondConfirmResults = append(secondConfirmResults, res)
}
recommandResults = secondConfirmResults
} }
return SearchResult{ return SearchResult{
RecommendType: hierarchy, RecommendType: hierarchy,
@ -915,7 +1089,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
isFallback := false isFallback := false
if len(recommends) == 0 { if len(recommends) == 0 {
logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err) logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err)
recommends, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, inputSlice) recommends, offset, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, recommendLenType, inputSlice)
if err != nil { if err != nil {
logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err)
return SearchResult{ return SearchResult{
@ -927,12 +1101,6 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
} }
} }
isFallback = true isFallback = true
if _, ok := fallbackSpecificSetKey(hierarchy, inputSlice); ok {
recommends = combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, recommends)
offset = recommendPrefixOffset(inputSlice[:len(inputSlice)-1])
} else {
offset = 0
}
} }
return SearchResult{ return SearchResult{
@ -979,7 +1147,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
logger.Error(ctx, "query key by redis fuzzy search failed", "query_key", fuzzyInput, "error", err) logger.Error(ctx, "query key by redis fuzzy search failed", "query_key", fuzzyInput, "error", err)
return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err)
} }
if len(results) == 0 { if len(results) == 0 {
// 如果没有结果,退一步(删除最后一个字符)并继续循环 // 如果没有结果,退一步(删除最后一个字符)并继续循环
// TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式
@ -1041,7 +1208,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
recommends = append(recommends, recommend) recommends = append(recommends, recommend)
} }
} }
if len(recommends) == 0 { if len(recommends) == 0 {
searchInput = trimLastRune(searchInput) searchInput = trimLastRune(searchInput)
searchInputLen = len([]rune(searchInput)) searchInputLen = len([]rune(searchInput))

View File

@ -2,6 +2,7 @@ package model
import ( import (
"fmt" "fmt"
"reflect"
"strings" "strings"
"testing" "testing"
@ -217,6 +218,138 @@ func TestNormalizeRecommendResultsKeepsParentPrefixOffsetForSpecificFallback(t *
} }
} }
func TestNormalizeRecommendResultsTrimsConfigFuzzySuffixes(t *testing.T) {
input := "110kV_TV-testProject1.cable_22-testProject1110kV_TV.base_extend.c"
offset := len([]rune(input))
results := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: []string{
input + "apacity",
input + "ategory",
input + "urrent",
input + "ode",
},
IsFuzzy: true,
Offset: offset,
},
}
got := normalizeRecommendResults(input, results)
result := got[constants.MeasTagRecommendHierarchyType.String()]
want := []string{"apacity", "ategory", "urrent", "ode"}
if result.Offset != offset {
t.Fatalf("expected offset %d, got %d", offset, result.Offset)
}
if !reflect.DeepEqual(result.QueryDatas, want) {
t.Fatalf("expected trimmed recommends %#v, got %#v", want, result.QueryDatas)
}
}
func TestNormalizeRecommendResultsTrimsToken4FallbackCandidates(t *testing.T) {
nsPath := "110kV_TV-testProject1"
input := nsPath + ".x"
offset := recommendPrefixOffset([]string{nsPath})
results := map[string]SearchResult{
constants.CompTagRecommendHierarchyType.String(): {
RecommendType: constants.CompTagRecommendHierarchyType,
QueryDatas: []string{
nsPath + ".cable_22-testProject1110kV_TV",
nsPath + ".cable_23-testProject1110kV_TV",
},
IsFuzzy: true,
IsFallback: true,
Offset: offset,
},
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: []string{
nsPath + ".IA_rms_CTA-testProject1",
nsPath + ".IB_rms_CTA-testProject1",
},
IsFuzzy: true,
IsFallback: true,
Offset: offset,
},
}
got := normalizeRecommendResults(input, results)
for key, result := range got {
if result.Offset != offset {
t.Fatalf("expected offset %d for %s, got %d", offset, key, result.Offset)
}
for _, recommend := range result.QueryDatas {
if strings.HasPrefix(recommend, nsPath+".") {
t.Fatalf("expected trimmed token4 fallback recommend for %s, got %s", key, recommend)
}
}
}
if got[constants.CompTagRecommendHierarchyType.String()].QueryDatas[0] != "cable_22-testProject1110kV_TV" {
t.Fatalf("expected token5 suffixes, got %v", got[constants.CompTagRecommendHierarchyType.String()].QueryDatas)
}
if got[constants.MeasTagRecommendHierarchyType.String()].QueryDatas[0] != "IA_rms_CTA-testProject1" {
t.Fatalf("expected token7 suffixes, got %v", got[constants.MeasTagRecommendHierarchyType.String()].QueryDatas)
}
}
func TestNormalizeRecommendResultsKeepsFullAndLocalConfigMeasurementSuffixesConsistent(t *testing.T) {
fullPrefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.base_extend"
localPrefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV.base_extend"
members := []string{"capacity", "category", "current"}
fullResults := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(fullPrefix+".", "."), members),
IsFuzzy: false,
},
}
localResults := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(localPrefix+".", "."), members),
IsFuzzy: false,
},
}
fullGot := normalizeRecommendResults(fullPrefix+".", fullResults)
localGot := normalizeRecommendResults(localPrefix+".", localResults)
fullRecommends := fullGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas
localRecommends := localGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas
if !reflect.DeepEqual(fullRecommends, localRecommends) {
t.Fatalf("expected full/local config measurement suffixes to match, full=%#v local=%#v", fullRecommends, localRecommends)
}
}
func TestNormalizeRecommendResultsKeepsFullAndLocalBayMeasurementSuffixesConsistent(t *testing.T) {
fullPrefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay"
localPrefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay"
members := []string{"IA_rms", "IB_rms", "IC_rms"}
fullResults := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(fullPrefix+".", "."), members),
IsFuzzy: false,
},
}
localResults := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(localPrefix+".", "."), members),
IsFuzzy: false,
},
}
fullGot := normalizeRecommendResults(fullPrefix+".", fullResults)
localGot := normalizeRecommendResults(localPrefix+".", localResults)
fullRecommends := fullGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas
localRecommends := localGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas
if !reflect.DeepEqual(fullRecommends, localRecommends) {
t.Fatalf("expected full/local bay measurement suffixes to match, full=%#v local=%#v", fullRecommends, localRecommends)
}
}
func TestRecommendGroupTokens(t *testing.T) { func TestRecommendGroupTokens(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -259,6 +392,242 @@ func TestRecommendGroupTokens(t *testing.T) {
} }
} }
func TestConfigRecommendCompTag(t *testing.T) {
tests := []struct {
name string
inputSlice []string
want string
wantOK bool
}{
{
name: "full token1 to token6 input",
inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "b"},
want: "comp_tag",
wantOK: true,
},
{
name: "local token4 to token6 input",
inputSlice: []string{"nspath", "comp_tag", "b"},
want: "comp_tag",
wantOK: true,
},
{
name: "missing comp tag",
inputSlice: []string{"nspath"},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotOK := configRecommendCompTag(tt.inputSlice)
if gotOK != tt.wantOK {
t.Fatalf("expected ok %t, got %t", tt.wantOK, gotOK)
}
if got != tt.want {
t.Fatalf("expected compTag %q, got %q", tt.want, got)
}
})
}
}
func TestFilterMembersByTrimmedPrefix(t *testing.T) {
tests := []struct {
name string
members []string
searchInput string
wantMembers []string
wantMatchInput string
}{
{
name: "config typo falls back to previous rune",
members: []string{"bay", "base_extend", "model", "rated", "stable", "component"},
searchInput: "bx",
wantMembers: []string{"bay", "base_extend"},
wantMatchInput: "b",
},
{
name: "measurement typo falls back to previous rune",
members: []string{"I_A_rms", "I_B_rms", "U_A_rms"},
searchInput: "Ix",
wantMembers: []string{"I_A_rms", "I_B_rms"},
wantMatchInput: "I",
},
{
name: "no fallback to empty prefix",
members: []string{"bay", "base_extend"},
searchInput: "x",
wantMembers: []string{},
wantMatchInput: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotMembers, gotMatchInput := filterMembersByTrimmedPrefix(tt.members, tt.searchInput)
if !reflect.DeepEqual(gotMembers, tt.wantMembers) {
t.Fatalf("expected members %#v, got %#v", tt.wantMembers, gotMembers)
}
if gotMatchInput != tt.wantMatchInput {
t.Fatalf("expected matched input %q, got %q", tt.wantMatchInput, gotMatchInput)
}
})
}
}
func TestDotAndTypoInputsShareTrimmedPrefixFallback(t *testing.T) {
tests := []struct {
name string
dotInput string
typoInput string
members []string
wantSuffix []string
}{
{
name: "token1 grid",
dotInput: "g.",
typoInput: "gx",
members: []string{"grid000", "grid001", "zone000"},
wantSuffix: []string{"grid000", "grid001"},
},
{
name: "token2 zone",
dotInput: "z.",
typoInput: "zx",
members: []string{"zone000", "zone001", "station000"},
wantSuffix: []string{"zone000", "zone001"},
},
{
name: "token3 station",
dotInput: "s.",
typoInput: "sx",
members: []string{"station000", "station001", "zone000"},
wantSuffix: []string{"station000", "station001"},
},
{
name: "token4 nspath",
dotInput: "1.",
typoInput: "1x",
members: []string{"110kV_TV-demoProject", "110kV_TV-testProject1", "220kV_TV-demoProject"},
wantSuffix: []string{"110kV_TV-demoProject", "110kV_TV-testProject1"},
},
{
name: "token5 component tag",
dotInput: "c.",
typoInput: "cx",
members: []string{"cable_22-testProject1110kV_TV", "cable_26-demoProject110kV_TV", "bay"},
wantSuffix: []string{"cable_22-testProject1110kV_TV", "cable_26-demoProject110kV_TV"},
},
{
name: "token6 config",
dotInput: "b.",
typoInput: "bx",
members: []string{"bay", "base_extend", "component"},
wantSuffix: []string{"bay", "base_extend"},
},
{
name: "token7 measurement",
dotInput: "I.",
typoInput: "Ix",
members: []string{"IA_rms", "IB_rms", "UA_rms"},
wantSuffix: []string{"IA_rms", "IB_rms"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dotSearchInput := strings.TrimSuffix(tt.dotInput, ".")
typoSearchInput := trimLastRune(tt.typoInput)
dotMembers, dotMatchedInput := filterMembersByTrimmedPrefix(tt.members, dotSearchInput)
typoMembers, typoMatchedInput := filterMembersByTrimmedPrefix(tt.members, typoSearchInput)
if dotMatchedInput != typoMatchedInput {
t.Fatalf("expected dot and typo matched input to be equal, dot=%q typo=%q", dotMatchedInput, typoMatchedInput)
}
if !reflect.DeepEqual(dotMembers, typoMembers) {
t.Fatalf("expected dot and typo members to be equal, dot=%#v typo=%#v", dotMembers, typoMembers)
}
if !reflect.DeepEqual(dotMembers, tt.wantSuffix) {
t.Fatalf("expected members %#v, got %#v", tt.wantSuffix, dotMembers)
}
})
}
}
func TestExactMatchChecksForInput(t *testing.T) {
tests := []struct {
name string
inputSlice []string
want []exactMatchCheck
}{
{
name: "token1 can be grid or local nspath",
inputSlice: []string{"g"},
want: []exactMatchCheck{
{setKey: constants.RedisAllGridSetKey, member: "g"},
{setKey: constants.RedisAllCompNSPathSetKey, member: "g"},
},
},
{
name: "token1 token2 can be zone comp tag or nspath meas",
inputSlice: []string{"grid000", "z"},
want: []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, "grid000"), member: "z"},
{setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "grid000"), member: "z"},
{setKey: constants.RedisAllCompTagSetKey, member: "z"},
{setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, "grid000"), member: "z"},
},
},
{
name: "token1 token2 token3 can be station or local config",
inputSlice: []string{"grid000", "zone000", "s"},
want: []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecZoneStationSetKey, "zone000"), member: "s"},
{setKey: constants.RedisAllConfigSetKey, member: "s"},
},
},
{
name: "token1 token2 token3 token4 can be nspath or local meas",
inputSlice: []string{"grid000", "zone000", "station000", "1"},
want: []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, "station000"), member: "1"},
{setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "zone000"), member: "1"},
},
},
{
name: "token1 through token5 component tag",
inputSlice: []string{"grid000", "zone000", "station000", "nspath", "c"},
want: []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "nspath"), member: "c"},
{setKey: constants.RedisAllCompTagSetKey, member: "c"},
},
},
{
name: "token1 through token6 config",
inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "b"},
want: []exactMatchCheck{
{setKey: constants.RedisAllConfigSetKey, member: "b"},
},
},
{
name: "token1 through token7 measurement",
inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "bay", "I"},
want: []exactMatchCheck{
{setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"), member: "I"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := exactMatchChecksForInput(tt.inputSlice)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("expected checks %#v, got %#v", tt.want, got)
}
})
}
}
func TestFallbackSpecificSetKey(t *testing.T) { func TestFallbackSpecificSetKey(t *testing.T) {
tests := []struct { tests := []struct {
name string name string