fix(recommend): handle trailing dots on complete measurements

- normalize redundant trailing dots for supported measurement formats
- validate exact measurement matches against component-specific Redis sets
- prevent terminal measurement matches from returning dot continuations
- add regression tests for offsets and exact completions
This commit is contained in:
douxu 2026-07-13 15:16:30 +08:00
parent 78f68b9c4f
commit 8195200cea
2 changed files with 145 additions and 5 deletions

View File

@ -215,7 +215,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
return handleLevelFuzzySearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompNSPathSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompNSPathSetKey, inputSlice)
}, },
func() SearchResult { func() SearchResult {
return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice)
}, },
) )
return finalizeResults(searchInput, results) return finalizeResults(searchInput, results)
@ -261,7 +261,21 @@ func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, inp
return input return input
} }
checks := exactMatchChecksForInput(strings.Split(trimmedInput, ".")) inputSlice := strings.Split(trimmedInput, ".")
if check, ok := completeMeasurementExactMatchCheck(inputSlice); ok {
exists, err := rdb.SIsMember(ctx, check.setKey, check.member).Result()
if err != nil {
logger.Warn(ctx, "check complete measurement with trailing dot failed", "input", input, "key", check.setKey, "member", check.member, "error", err)
return input
}
if exists {
// A measurement is terminal. Its trailing dot cannot enter another
// hierarchy level, so search the complete measurement without it.
return trimmedInput
}
}
checks := exactMatchChecksForInput(inputSlice)
if len(checks) == 0 { if len(checks) == 0 {
return input return input
} }
@ -279,6 +293,30 @@ func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, inp
return trimmedInput return trimmedInput
} }
// completeMeasurementExactMatchCheck returns the authoritative Redis lookup
// for each supported complete measurement identity shape.
func completeMeasurementExactMatchCheck(inputSlice []string) (exactMatchCheck, bool) {
switch len(inputSlice) {
case 7: // token1.token2.token3.token4.token5.token6.token7
return exactMatchCheck{
setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[4]),
member: inputSlice[6],
}, true
case 4: // token4.token5.token6.token7
return exactMatchCheck{
setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[1]),
member: inputSlice[3],
}, true
case 2: // token4.token7
return exactMatchCheck{
setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, inputSlice[0]),
member: inputSlice[1],
}, true
default:
return exactMatchCheck{}, false
}
}
type exactMatchCheck struct { type exactMatchCheck struct {
setKey string setKey string
member string member string
@ -1087,9 +1125,10 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
} }
} }
keyExists, err := rdb.SIsMember(ctx, redisSetKey, searchInput).Result() exactSetKey := exactSearchRedisSetKey(hierarchy, inputSlice, redisSetKey)
keyExists, err := rdb.SIsMember(ctx, exactSetKey, searchInput).Result()
if err != nil { if err != nil {
logger.Error(ctx, "check key exist from redis set failed ", "key", redisSetKey, "error", err) logger.Error(ctx, "check key exist from redis set failed ", "key", exactSetKey, "error", err)
return SearchResult{ return SearchResult{
RecommendType: hierarchy, RecommendType: hierarchy,
QueryDatas: nil, QueryDatas: nil,
@ -1153,6 +1192,16 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
} }
} }
// exactSearchRedisSetKey uses the component-specific measurement set for
// token7, ensuring an exact hit belongs to the component in the input path.
func exactSearchRedisSetKey(hierarchy constants.RecommendHierarchyType, inputSlice []string, defaultSetKey string) string {
if hierarchy == constants.MeasTagRecommendHierarchyType && len(inputSlice) >= 3 {
compTag := inputSlice[len(inputSlice)-3]
return fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag)
}
return defaultSetKey
}
// runFuzzySearch 执行 RediSearch 模糊搜索, 并用 Redis set 校验候选项确属当前层级。 // runFuzzySearch 执行 RediSearch 模糊搜索, 并用 Redis set 校验候选项确属当前层级。
func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) { func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) {
var configToken string var configToken string
@ -1189,7 +1238,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
} }
if len(results) == 0 { if len(results) == 0 {
if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) {
return []string{"."}, fuzzyRecommendOffset(searchPrefix, searchInput), nil return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil
} }
// 如果没有结果,退一步(删除最后一个字符)并继续循环 // 如果没有结果,退一步(删除最后一个字符)并继续循环

View File

@ -56,6 +56,43 @@ func TestFuzzyRecommendMemberSetKeyUsesLocalNSPathSet(t *testing.T) {
} }
} }
func TestMeasurementExactSearchUsesComponentSpecificSet(t *testing.T) {
tests := []struct {
name string
inputSlice []string
want string
}{
{
name: "token1 through token7",
inputSlice: []string{"grid", "zone", "station", "nspath", "comp_tag", "bay", "measurement"},
want: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"),
},
{
name: "token4 through token7",
inputSlice: []string{"nspath", "comp_tag", "bay", "measurement"},
want: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := exactSearchRedisSetKey(constants.MeasTagRecommendHierarchyType, tt.inputSlice, constants.RedisAllMeasTagSetKey)
if got != tt.want {
t.Fatalf("expected exact search set %q, got %q", tt.want, got)
}
})
}
}
func TestFuzzyExactContinuationValueTreatsMeasurementAsTerminal(t *testing.T) {
if got := fuzzyExactContinuationValue(constants.MeasTagRecommendHierarchyType); got != "" {
t.Fatalf("expected empty completion for measurement, got %q", got)
}
if got := fuzzyExactContinuationValue(constants.CompNSPathRecommendHierarchyType); got != "." {
t.Fatalf("expected level continuation for component nspath, got %q", got)
}
}
func TestNormalizeRecommendResultsKeepsFuzzyExactContinuation(t *testing.T) { func TestNormalizeRecommendResultsKeepsFuzzyExactContinuation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -772,6 +809,60 @@ func TestExactMatchChecksForInput(t *testing.T) {
} }
} }
func TestCompleteMeasurementTrailingDotChecks(t *testing.T) {
tests := []struct {
name string
input string
wantOffset int
wantCheck exactMatchCheck
}{
{
name: "token1 through token7",
input: "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay.IA_rms_CTA-testProject1.",
wantOffset: 105,
wantCheck: exactMatchCheck{
setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "cable_22-testProject1110kV_TV"),
member: "IA_rms_CTA-testProject1",
},
},
{
name: "token4 through token7",
input: "110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay.IA_rms_CTA-testProject1.",
wantOffset: 78,
wantCheck: exactMatchCheck{
setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "cable_22-testProject1110kV_TV"),
member: "IA_rms_CTA-testProject1",
},
},
{
name: "token4 and token7",
input: "110kV_TV-testProject1.IA_rms_CTA-testProject1.",
wantOffset: 45,
wantCheck: exactMatchCheck{
setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, "110kV_TV-testProject1"),
member: "IA_rms_CTA-testProject1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
measurement := strings.TrimSuffix(tt.input, ".")
if got := len([]rune(measurement)); got != tt.wantOffset {
t.Fatalf("expected offset %d, got %d", tt.wantOffset, got)
}
got, ok := completeMeasurementExactMatchCheck(strings.Split(measurement, "."))
if !ok {
t.Fatal("expected complete measurement exact-match check")
}
if !reflect.DeepEqual(got, tt.wantCheck) {
t.Fatalf("expected check %#v, got %#v", tt.wantCheck, got)
}
})
}
}
func TestFallbackSpecificSetKey(t *testing.T) { func TestFallbackSpecificSetKey(t *testing.T) {
tests := []struct { tests := []struct {
name string name string