Skip to content

Commit 615055a

Browse files
xueyizhengclaude
andcommitted
feat(expt-item-centric): 放开 createExpt filter 普通列 + tag, set 级下传服务端裁剪
创建侧白名单放开普通业务列 (field_name 任意, field_type 加 string/double/bool/float/integer, query_type 加 match/not_match 对齐下游 data_filter 词表), field_name 改为只校验非空。 matcher: contains/not_contains 重命名为 match/not_match (对齐 data 模块标准 词, 逻辑不变); matchFilterField 加 field_type=tag 分支, 读 item.Tags 的 TagName 做存在性匹配。 scheduler exptStartMultiSet: 新增 extractNormalColumnFilter (抽普通列 -> entity.Filter) + extractTagFilter (抽 tag -> entity.TagFilter), List/BatchGet 两路拉取都带 Filter+TagFilter 下传。commercial 走 ml_flow 服务端裁剪, 开源版 下游无字段降级全量 (抽象-实现分离)。item_id not_in 仍逐页内存排除 (下游无 not_in 专用字段), 补注释说明非全集进内存 + TODO。 单测: 普通列放行/非法 query_type/field_name 空; match/not_match; tag 命中/ 无 tag/端到端; extractNormalColumnFilter/extractTagFilter。全 PASS。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f89327 commit 615055a

6 files changed

Lines changed: 333 additions & 29 deletions

backend/modules/evaluation/domain/entity/expt_eval_set_config_validation.go

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ var (
2424
// alias 字符集白名单 [a-zA-Z0-9_-]
2525
aliasPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
2626

27-
// filter 字段白名单
28-
allowedFilterFieldNames = map[string]struct{}{"item_id": {}} // tag key 走 field_type=tag 放行
29-
allowedFilterFieldTypes = map[string]struct{}{"long": {}, "tag": {}} // long(item_id) / tag
30-
allowedFilterQueryTypes = map[string]struct{}{"eq": {}, "not_eq": {}, "in": {}, "not_in": {}} // 单层比较
27+
// filter 字段白名单 (对齐下游 data_filter.thrift FieldType / QueryType 词表)
28+
// field_type: long(item_id) / tag(标签) / string,double,bool,float,integer(普通业务列)
29+
// query_type: 单层比较 + match/not_match(子串)
30+
// field_name: 只校验非空 (item_id / tag key / 普通列名), 不查 schema 是否存在
31+
allowedFilterFieldTypes = map[string]struct{}{
32+
"long": {}, "tag": {},
33+
"string": {}, "double": {}, "bool": {}, "float": {}, "integer": {},
34+
}
35+
allowedFilterQueryTypes = map[string]struct{}{
36+
"eq": {}, "not_eq": {}, "in": {}, "not_in": {}, "match": {}, "not_match": {},
37+
}
3138
)
3239

3340
// ValidateEvalSetConfigs 校验新路径 (MultiSetConfig) 的多评测集配置。
@@ -103,7 +110,8 @@ func ValidateEvalSetConfigs(configs []*EvalSetConfig) error {
103110
return nil
104111
}
105112

106-
// validateFilter 对 item_filter / evaluator filter 套用白名单:字段名/类型/操作符白名单、单层不嵌套、数量上限、点选基本校验。
113+
// validateFilter 对 item_filter / evaluator filter 套用白名单:field_type/query_type 白名单、
114+
// field_name 非空、数量上限、item_id 点选基本校验。支持 item_id / tag / 普通业务列三类。
107115
func validateFilter(f *ExptItemFilter, path string) error {
108116
if f == nil {
109117
return nil
@@ -120,17 +128,15 @@ func validateFilter(f *ExptItemFilter, path string) error {
120128
}
121129
// field_type 白名单
122130
if _, ok := allowedFilterFieldTypes[ff.FieldType]; !ok {
123-
return invalidParam(fmt.Sprintf("%s.filter_fields[%d]: field_type %q not allowed (only long/tag)", path, fi, ff.FieldType))
131+
return invalidParam(fmt.Sprintf("%s.filter_fields[%d]: field_type %q not allowed (long/tag/string/double/bool/float/integer)", path, fi, ff.FieldType))
124132
}
125133
// query_type 白名单
126134
if _, ok := allowedFilterQueryTypes[ff.QueryType]; !ok {
127-
return invalidParam(fmt.Sprintf("%s.filter_fields[%d]: query_type %q not allowed (only eq/not_eq/in/not_in)", path, fi, ff.QueryType))
135+
return invalidParam(fmt.Sprintf("%s.filter_fields[%d]: query_type %q not allowed (eq/not_eq/in/not_in/match/not_match)", path, fi, ff.QueryType))
128136
}
129-
// field_name 白名单: item_id 显式放行;其余仅当 field_type=tag 时视为 tag key 放行
130-
if _, ok := allowedFilterFieldNames[ff.FieldName]; !ok {
131-
if ff.FieldType != "tag" {
132-
return invalidParam(fmt.Sprintf("%s.filter_fields[%d]: field_name %q not allowed (only item_id or tag key with field_type=tag)", path, fi, ff.FieldName))
133-
}
137+
// field_name 只校验非空 (item_id / tag key / 普通列名), 不查评测集 schema 是否存在
138+
if ff.FieldName == "" {
139+
return invalidParam(fmt.Sprintf("%s.filter_fields[%d]: field_name must not be empty", path, fi))
134140
}
135141
// 点选 (item_id in) 基本校验: values 非空。
136142
// TODO: 文档要求"点选 values 必须全部属于对应 eval_set_version 快照(缺一报错)",依赖 Data 侧按 version 拉 item 接口,

backend/modules/evaluation/domain/entity/expt_eval_set_config_validation_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,35 @@ func TestValidateEvalSetConfigs(t *testing.T) {
9999
{EvalSetID: 1, EvalSetVersionID: 11, EvaluatorConfs: []*ExptEvaluatorConf{
100100
{EvaluatorVersionID: 100, Filter: &ExptItemFilter{
101101
FilterFields: []*ExptItemFilterField{
102-
{FieldName: "item_id", FieldType: "double", QueryType: "eq"},
102+
{FieldName: "item_id", FieldType: "unknown_type", QueryType: "eq"},
103103
},
104104
}},
105105
}},
106106
},
107107
wantErr: true,
108108
},
109+
{
110+
name: "非法: filter query_type 超白名单 (contains 已不在白名单)",
111+
configs: []*EvalSetConfig{
112+
{EvalSetID: 1, EvalSetVersionID: 11, ItemFilter: &ExptItemFilter{
113+
FilterFields: []*ExptItemFilterField{
114+
{FieldName: "category", FieldType: "string", QueryType: "contains"},
115+
},
116+
}},
117+
},
118+
wantErr: true,
119+
},
120+
{
121+
name: "非法: filter field_name 为空",
122+
configs: []*EvalSetConfig{
123+
{EvalSetID: 1, EvalSetVersionID: 11, ItemFilter: &ExptItemFilter{
124+
FilterFields: []*ExptItemFilterField{
125+
{FieldName: "", FieldType: "string", QueryType: "eq", Values: []string{"x"}},
126+
},
127+
}},
128+
},
129+
wantErr: true,
130+
},
109131
{
110132
name: "非法: filter query_type 超白名单",
111133
configs: []*EvalSetConfig{
@@ -151,6 +173,31 @@ func TestValidateEvalSetConfigs(t *testing.T) {
151173
},
152174
wantErr: false,
153175
},
176+
{
177+
name: "合法: 普通列 string + match",
178+
configs: []*EvalSetConfig{
179+
{EvalSetID: 1, EvalSetVersionID: 11, ItemFilter: &ExptItemFilter{
180+
FilterFields: []*ExptItemFilterField{
181+
{FieldName: "category", FieldType: "string", QueryType: "match", Values: []string{"math"}},
182+
},
183+
}},
184+
},
185+
wantErr: false,
186+
},
187+
{
188+
name: "合法: 普通列 + tag + item_id 混合",
189+
configs: []*EvalSetConfig{
190+
{EvalSetID: 1, EvalSetVersionID: 11, ItemFilter: &ExptItemFilter{
191+
QueryAndOr: "and",
192+
FilterFields: []*ExptItemFilterField{
193+
{FieldName: "item_id", FieldType: "long", QueryType: "in", Values: []string{"1"}},
194+
{FieldName: "lang", FieldType: "tag", QueryType: "eq", Values: []string{"zh"}},
195+
{FieldName: "difficulty", FieldType: "integer", QueryType: "not_eq", Values: []string{"5"}},
196+
},
197+
}},
198+
},
199+
wantErr: false,
200+
},
154201
}
155202

156203
for _, tt := range tests {

backend/modules/evaluation/domain/service/expt_item_filter_match.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,8 @@ func MatchExptItemFilter(filter *entity.ExptItemFilter, item *entity.EvaluationS
7676

7777
// matchFilterField 单字段匹配。
7878
// - field_name=item_id: 用 item.ItemID 比较 (item 级, 不依赖 turn)
79-
// - 其余: 从 turn.FieldDataList 按 name/key 取字段文本值
80-
//
81-
// 注: tag (field_type=tag) 暂未支持 — matcher 尚无 tag 分支, 配 tag 会走文本路径匹配不上,
82-
// 见 tech debt; 普通 turn 字段创建侧白名单当前未放行 (仅 item_id / tag)。
79+
// - field_type=tag: 用 item.Tags 里持有的 tag 名集合做存在性比较 (对齐下游 TagFilter 语义)
80+
// - 其余 (普通列): 从 turn.FieldDataList 按 name/key 取字段文本值
8381
func matchFilterField(ff *entity.ExptItemFilterField, item *entity.EvaluationSetItem, turn *entity.Turn) bool {
8482
// item_id: 直接读 item 级 ItemID, 不走 turn 文本路径
8583
if ff.FieldName == "item_id" {
@@ -89,17 +87,52 @@ func matchFilterField(ff *entity.ExptItemFilterField, item *entity.EvaluationSet
8987
return matchByQueryType(ff.QueryType, strconv.FormatInt(item.ItemID, 10), ff.Values)
9088
}
9189

90+
// tag: 按 item.Tags 的 TagName 存在性匹配。
91+
// 期望 tag 名取自 ff.Values (与 set 级 extractTagFilter 收集口径一致); 命中=item 含其一。
92+
if strings.EqualFold(ff.FieldType, "tag") {
93+
return matchTagField(ff, item)
94+
}
95+
9296
actual, ok := getFieldTextValue(ff.FieldName, item, turn)
9397
if !ok {
9498
return matchMissingField(ff.QueryType)
9599
}
96100
return matchByQueryType(ff.QueryType, actual, ff.Values)
97101
}
98102

103+
// matchTagField 按 item 持有的 tag 名集合做 in/not_in 风格匹配。
104+
// - in/eq/match: item 含 ff.Values 中任一 tag → 命中
105+
// - not_in/not_eq/not_match: item 不含任一 → 命中 (取反)
106+
// - item 无 tag: 走 matchMissingField (in→不命中, not_in→命中)
107+
func matchTagField(ff *entity.ExptItemFilterField, item *entity.EvaluationSetItem) bool {
108+
if item == nil || len(item.Tags) == 0 {
109+
return matchMissingField(ff.QueryType)
110+
}
111+
owned := make(map[string]struct{}, len(item.Tags))
112+
for _, t := range item.Tags {
113+
if t != nil && t.TagName != "" {
114+
owned[t.TagName] = struct{}{}
115+
}
116+
}
117+
hit := false
118+
for _, v := range ff.Values {
119+
if _, ok := owned[v]; ok {
120+
hit = true
121+
break
122+
}
123+
}
124+
switch strings.ToLower(ff.QueryType) {
125+
case "not_equal", "ne", "not_in", "not_eq", "not_match":
126+
return !hit
127+
default: // eq/in/match/空
128+
return hit
129+
}
130+
}
131+
99132
// matchMissingField 字段不存在时的语义: equal/in 不命中 (false); not_equal/not_in 取反命中 (true)。
100133
func matchMissingField(queryType string) bool {
101134
switch strings.ToLower(queryType) {
102-
case "not_equal", "ne", "not_in", "not_eq":
135+
case "not_equal", "ne", "not_in", "not_eq", "not_match":
103136
return true
104137
}
105138
return false
@@ -143,14 +176,14 @@ func matchByQueryType(queryType, actual string, values []string) bool {
143176
}
144177
}
145178
return true
146-
case "contains":
179+
case "match":
147180
for _, v := range values {
148181
if strings.Contains(actual, v) {
149182
return true
150183
}
151184
}
152185
return false
153-
case "not_contains":
186+
case "not_match":
154187
for _, v := range values {
155188
if strings.Contains(actual, v) {
156189
return false

backend/modules/evaluation/domain/service/expt_item_filter_match_test.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,38 @@ func TestMatchExptItemFilter_OrLogic_AnyMatch(t *testing.T) {
133133
assert.True(t, matched, "OR: 任一字段命中即命中")
134134
}
135135

136-
func TestMatchByQueryType_Contains(t *testing.T) {
136+
func TestMatchByQueryType_Match(t *testing.T) {
137137
turn := turnWith("content", "the quick brown fox")
138138
matched, err := MatchExptItemFilter(&entity.ExptItemFilter{
139139
FilterFields: []*entity.ExptItemFilterField{
140-
{FieldName: "content", QueryType: "contains", Values: []string{"quick"}},
140+
{FieldName: "content", FieldType: "string", QueryType: "match", Values: []string{"quick"}},
141141
},
142142
}, nil, turn)
143143
assert.NoError(t, err)
144144
assert.True(t, matched)
145145
}
146146

147+
func TestMatchByQueryType_NotMatch(t *testing.T) {
148+
turn := turnWith("content", "the quick brown fox")
149+
// not_match: 子串不存在才命中
150+
matched, err := MatchExptItemFilter(&entity.ExptItemFilter{
151+
FilterFields: []*entity.ExptItemFilterField{
152+
{FieldName: "content", FieldType: "string", QueryType: "not_match", Values: []string{"slow"}},
153+
},
154+
}, nil, turn)
155+
assert.NoError(t, err)
156+
assert.True(t, matched)
157+
158+
// not_match: 子串存在则不命中
159+
matched, err = MatchExptItemFilter(&entity.ExptItemFilter{
160+
FilterFields: []*entity.ExptItemFilterField{
161+
{FieldName: "content", FieldType: "string", QueryType: "not_match", Values: []string{"quick"}},
162+
},
163+
}, nil, turn)
164+
assert.NoError(t, err)
165+
assert.False(t, matched)
166+
}
167+
147168
func TestMatchByQueryType_NotEqual_FieldMissing_True(t *testing.T) {
148169
// 字段不存在时 not_equal 应返回 true (按取反语义,缺失=不等)
149170
turn := &entity.Turn{}
@@ -226,3 +247,67 @@ func TestShouldRunByFilter_ItemID(t *testing.T) {
226247
assert.NoError(t, err)
227248
assert.False(t, run, "Exclude + item_id 命中 → 不跑")
228249
}
250+
251+
// TestMatchFilterField_Tag 验证 field_type=tag 走 item.Tags 的 TagName 存在性匹配。
252+
func TestMatchFilterField_Tag(t *testing.T) {
253+
item := &entity.EvaluationSetItem{
254+
ItemID: 1,
255+
Tags: []*entity.ResourceTag{{TagName: "zh"}, {TagName: "hard"}},
256+
}
257+
turn := &entity.Turn{} // tag 不依赖 turn
258+
259+
cases := []struct {
260+
name string
261+
queryType string
262+
values []string
263+
want bool
264+
}{
265+
{"in 命中(含 zh)", "in", []string{"zh", "en"}, true},
266+
{"in 不命中(都不含)", "in", []string{"en", "fr"}, false},
267+
{"eq 命中", "eq", []string{"hard"}, true},
268+
{"not_in 命中(不含→取反)", "not_in", []string{"en"}, true},
269+
{"not_in 不命中(含→取反)", "not_in", []string{"zh"}, false},
270+
{"match 命中(走存在性,不做子串)", "match", []string{"zh"}, true},
271+
{"not_match 含→不命中", "not_match", []string{"zh"}, false},
272+
}
273+
for _, c := range cases {
274+
t.Run(c.name, func(t *testing.T) {
275+
ff := &entity.ExptItemFilterField{FieldName: "lang", FieldType: "tag", QueryType: c.queryType, Values: c.values}
276+
got := matchFilterField(ff, item, turn)
277+
assert.Equal(t, c.want, got)
278+
})
279+
}
280+
}
281+
282+
// TestMatchFilterField_Tag_NoTags item 无 tag 时走 matchMissingField (in→不命中, not_in→命中)。
283+
func TestMatchFilterField_Tag_NoTags(t *testing.T) {
284+
item := &entity.EvaluationSetItem{ItemID: 1} // 无 Tags
285+
turn := &entity.Turn{}
286+
287+
in := &entity.ExptItemFilterField{FieldName: "lang", FieldType: "tag", QueryType: "in", Values: []string{"zh"}}
288+
assert.False(t, matchFilterField(in, item, turn), "无 tag + in → 不命中")
289+
290+
notIn := &entity.ExptItemFilterField{FieldName: "lang", FieldType: "tag", QueryType: "not_in", Values: []string{"zh"}}
291+
assert.True(t, matchFilterField(notIn, item, turn), "无 tag + not_in → 命中")
292+
}
293+
294+
// TestShouldRunByFilter_Tag 端到端: Include + tag 命中→跑; 不命中→不跑。
295+
func TestShouldRunByFilter_Tag(t *testing.T) {
296+
item := &entity.EvaluationSetItem{ItemID: 1, Tags: []*entity.ResourceTag{{TagName: "zh"}}}
297+
turn := &entity.Turn{}
298+
299+
hit := &entity.ExptItemFilter{FilterFields: []*entity.ExptItemFilterField{
300+
{FieldName: "lang", FieldType: "tag", QueryType: "in", Values: []string{"zh"}},
301+
}}
302+
miss := &entity.ExptItemFilter{FilterFields: []*entity.ExptItemFilterField{
303+
{FieldName: "lang", FieldType: "tag", QueryType: "in", Values: []string{"en"}},
304+
}}
305+
306+
run, err := ShouldRunByFilter(hit, filterModeInclude, item, turn)
307+
assert.NoError(t, err)
308+
assert.True(t, run, "Include + tag 命中 → 跑")
309+
310+
run, err = ShouldRunByFilter(miss, filterModeInclude, item, turn)
311+
assert.NoError(t, err)
312+
assert.False(t, run, "Include + tag 不命中 → 不跑(Skipped)")
313+
}

0 commit comments

Comments
 (0)