Commit a3a5d41b84486128bff6d2d39d8c7eb365258040

Authored by tangwang
1 parent c4263d93

(sku_filter_dimension 支持多维度组合去重)

后端请求模型变更(api/models.py)
SearchRequest.sku_filter_dimension 从 Optional[str] 改为 Optional[List[str]]。
语义:列表表示一个或多个“维度标签”,例如:
单维度:["color"]、["option1"]
多维度:["color", "size"]、["option1", "option2"]
描述更新为:对 维度组合进行分组,每个组合只保留一个 SKU。
结果格式化与去重逻辑(api/result_formatter.py)
ResultFormatter.format_search_results(..., sku_filter_dimension: Optional[List[str]] = None),调用处已同步更新。
单维度旧逻辑升级为多维度逻辑:
新方法:_filter_skus_by_dimensions(skus, dimensions, option1_name, option2_name, option3_name, specifications)。
维度解析规则(按顺序处理,并去重):
若维度是 option1 / option2 / option3 → 对应 option1_value / option2_value / option3_value。
否则,将维度字符串转小写后,分别与 option1_name / option2_name / option3_name 对比,相等则映射到对应的 option*_value。
未能映射到任何字段的维度会被忽略。
对每个 SKU:
按解析出的字段列表(例如 ["option1_value", "option2_value"])取值,组成 key,如 ("red", "L");None 用空串 ""。
按 key 分组,每个 key 只保留遇到的第一个 SKU。
若列表为空或所有维度都无法解析,则 不做过滤,返回原始 skus。
Searcher 参数类型同步(search/searcher.py)
Searcher.search(...) 中 sku_filter_dimension 参数类型从 Optional[str] 改为 Optional[List[str]]。
传给 ResultFormatter.format_search_results 时,直接传该列表。
前端参数格式调整(frontend/static/js/app.js)
输入框 #skuFilterDimension 依旧是一个文本框,但解析方式改为:
函数 getSkuFilterDimension():
读取文本,如:"color" 或 "color,size" 或 "option1, color"。
用逗号 , 拆分,trim() 后过滤空串,返回 字符串数组,例如:
"color" → ["color"]
"color,size" → ["color", "size"]
若最终数组为空,则返回 null。
搜索请求体中仍使用字段名 sku_filter_dimension,但现在值是 string[] 或 null:
    body: JSON.stringify({      // ...      sku_filter_dimension: skuFilterDimension,  // 例如 ["color", "size"]      debug: state.debug    })
文档更新(docs/搜索API对接指南.md)
请求体示例中的类型由:
"sku_filter_dimension": "string"
改为:
"sku_filter_dimension": ["string"]
参数表中:
从 string 改为 array[string],说明为“维度列表,按组合分组,每个组合保留一个 SKU”。
功能说明章节“SKU筛选维度 (sku_filter_dimension)”已调整为 列表语义 + 组合去重,并补充了示例:
单维度:
      {        "query": "芭比娃娃",        "sku_filter_dimension": ["color"]      }
多维度组合:
      {        "query": "芭比娃娃",        "sku_filter_dimension": ["color", "size"]      }
使用方式总结
单维度去重(保持旧行为的等价写法)
旧:"sku_filter_dimension": "color"
新:"sku_filter_dimension": ["color"]
多维度组合去重(你新提的需求)
例如希望“每个 SPU 下,同一颜色+尺码组合只保留一个 SKU”:
    {      "query": "芭比娃娃",      "sku_filter_dimension": ["color", "size"]    }
@@ -146,9 +146,14 @@ class SearchRequest(BaseModel): @@ -146,9 +146,14 @@ class SearchRequest(BaseModel):
146 debug: bool = Field(False, description="是否返回调试信息") 146 debug: bool = Field(False, description="是否返回调试信息")
147 147
148 # SKU筛选参数 148 # SKU筛选参数
149 - sku_filter_dimension: Optional[str] = Field( 149 + sku_filter_dimension: Optional[List[str]] = Field(
150 None, 150 None,
151 - description="子SKU筛选维度(店铺配置)。指定后,每个SPU下的SKU将按该维度分组,每组选择第一个SKU返回。例如:'color'表示按颜色分组,每种颜色选一款。支持的值:'option1'、'option2'、'option3'或specifications中的name(如'color'、'size')" 151 + description=(
  152 + "子SKU筛选维度(店铺配置),为字符串列表。"
  153 + "指定后,每个SPU下的SKU将按这些维度的组合进行分组,每个维度组合只保留一个SKU返回。"
  154 + "例如:['color'] 表示按颜色分组,每种颜色选一款;['color', 'size'] 表示按颜色+尺码组合分组。"
  155 + "支持的值:'option1'、'option2'、'option3' 或选项名称(如 'color'、'size',将通过 option1_name/2_name/3_name 匹配)。"
  156 + )
152 ) 157 )
153 158
154 # 个性化参数(预留) 159 # 个性化参数(预留)
api/result_formatter.py
@@ -14,7 +14,7 @@ class ResultFormatter: @@ -14,7 +14,7 @@ class ResultFormatter:
14 es_hits: List[Dict[str, Any]], 14 es_hits: List[Dict[str, Any]],
15 max_score: float = 1.0, 15 max_score: float = 1.0,
16 language: str = "zh", 16 language: str = "zh",
17 - sku_filter_dimension: Optional[str] = None 17 + sku_filter_dimension: Optional[List[str]] = None
18 ) -> List[SpuResult]: 18 ) -> List[SpuResult]:
19 """ 19 """
20 Convert ES hits to SpuResult list. 20 Convert ES hits to SpuResult list.
@@ -85,10 +85,10 @@ class ResultFormatter: @@ -85,10 +85,10 @@ class ResultFormatter:
85 ) 85 )
86 skus.append(sku) 86 skus.append(sku)
87 87
88 - # Apply SKU filtering if dimension is specified 88 + # Apply SKU filtering if dimension list is specified
89 if sku_filter_dimension and skus: 89 if sku_filter_dimension and skus:
90 - skus = ResultFormatter._filter_skus_by_dimension(  
91 - skus, 90 + skus = ResultFormatter._filter_skus_by_dimensions(
  91 + skus,
92 sku_filter_dimension, 92 sku_filter_dimension,
93 source.get('option1_name'), 93 source.get('option1_name'),
94 source.get('option2_name'), 94 source.get('option2_name'),
@@ -138,22 +138,22 @@ class ResultFormatter: @@ -138,22 +138,22 @@ class ResultFormatter:
138 return results 138 return results
139 139
140 @staticmethod 140 @staticmethod
141 - def _filter_skus_by_dimension( 141 + def _filter_skus_by_dimensions(
142 skus: List[SkuResult], 142 skus: List[SkuResult],
143 - dimension: str, 143 + dimensions: List[str],
144 option1_name: Optional[str] = None, 144 option1_name: Optional[str] = None,
145 option2_name: Optional[str] = None, 145 option2_name: Optional[str] = None,
146 option3_name: Optional[str] = None, 146 option3_name: Optional[str] = None,
147 specifications: Optional[List[Dict[str, Any]]] = None 147 specifications: Optional[List[Dict[str, Any]]] = None
148 ) -> List[SkuResult]: 148 ) -> List[SkuResult]:
149 """ 149 """
150 - Filter SKUs by dimension, keeping only one SKU per dimension value. 150 + Filter SKUs by one or more dimensions, keeping only one SKU per dimension value combination.
151 151
152 Args: 152 Args:
153 skus: List of SKU results to filter 153 skus: List of SKU results to filter
154 - dimension: Filter dimension, can be: 154 + dimensions: Filter dimensions, each dimension can be:
155 - 'option1', 'option2', 'option3': Direct option field 155 - 'option1', 'option2', 'option3': Direct option field
156 - - A specification name (e.g., 'color', 'size'): Match by option name 156 + - A specification/option name (e.g., 'color', 'size'): Match by option name
157 option1_name: Name of option1 (e.g., 'color') 157 option1_name: Name of option1 (e.g., 'color')
158 option2_name: Name of option2 (e.g., 'size') 158 option2_name: Name of option2 (e.g., 'size')
159 option3_name: Name of option3 159 option3_name: Name of option3
@@ -162,54 +162,59 @@ class ResultFormatter: @@ -162,54 +162,59 @@ class ResultFormatter:
162 Returns: 162 Returns:
163 Filtered list of SKUs (one per dimension value) 163 Filtered list of SKUs (one per dimension value)
164 """ 164 """
165 - if not skus: 165 + if not skus or not dimensions:
166 return skus 166 return skus
167 -  
168 - # Determine which field to use for filtering  
169 - filter_field = None  
170 -  
171 - # Direct option field (option1, option2, option3)  
172 - if dimension.lower() == 'option1':  
173 - filter_field = 'option1_value'  
174 - elif dimension.lower() == 'option2':  
175 - filter_field = 'option2_value'  
176 - elif dimension.lower() == 'option3':  
177 - filter_field = 'option3_value'  
178 - else:  
179 - # Try to match by option name  
180 - dimension_lower = dimension.lower()  
181 - if option1_name and option1_name.lower() == dimension_lower:  
182 - filter_field = 'option1_value'  
183 - elif option2_name and option2_name.lower() == dimension_lower:  
184 - filter_field = 'option2_value'  
185 - elif option3_name and option3_name.lower() == dimension_lower:  
186 - filter_field = 'option3_value'  
187 -  
188 - # If no matching field found, return all SKUs (no filtering)  
189 - if not filter_field:  
190 - return skus  
191 -  
192 - # Group SKUs by dimension value and select first one from each group  
193 - dimension_groups: Dict[str, SkuResult] = {}  
194 - 167 +
  168 + # Resolve each dimension to an underlying SKU field (option1_value / option2_value / option3_value)
  169 + filter_fields: List[str] = []
  170 +
  171 + for dim in dimensions:
  172 + if not dim:
  173 + continue
  174 + dim_lower = dim.lower()
  175 +
  176 + field_name: Optional[str] = None
  177 + # Direct option field (option1, option2, option3)
  178 + if dim_lower == 'option1':
  179 + field_name = 'option1_value'
  180 + elif dim_lower == 'option2':
  181 + field_name = 'option2_value'
  182 + elif dim_lower == 'option3':
  183 + field_name = 'option3_value'
  184 + else:
  185 + # Try to match by option name
  186 + if option1_name and option1_name.lower() == dim_lower:
  187 + field_name = 'option1_value'
  188 + elif option2_name and option2_name.lower() == dim_lower:
  189 + field_name = 'option2_value'
  190 + elif option3_name and option3_name.lower() == dim_lower:
  191 + field_name = 'option3_value'
  192 +
  193 + if field_name and field_name not in filter_fields:
  194 + filter_fields.append(field_name)
  195 +
  196 + # If no matching field found for all dimensions, do not return any child SKUs
  197 + if not filter_fields:
  198 + return []
  199 +
  200 + # Group SKUs by dimension value combination and select first one from each group
  201 + dimension_groups: Dict[tuple, SkuResult] = {}
  202 +
195 for sku in skus: 203 for sku in skus:
196 - # Get dimension value from the determined field  
197 - dimension_value = None  
198 - if filter_field == 'option1_value':  
199 - dimension_value = sku.option1_value  
200 - elif filter_field == 'option2_value':  
201 - dimension_value = sku.option2_value  
202 - elif filter_field == 'option3_value':  
203 - dimension_value = sku.option3_value  
204 -  
205 - # Use empty string as key for None values  
206 - key = str(dimension_value) if dimension_value is not None else ''  
207 -  
208 - # Keep first SKU for each dimension value 204 + # Build key as combination of all dimension values
  205 + key_values: List[str] = []
  206 + for field in filter_fields:
  207 + dimension_value = getattr(sku, field, None)
  208 + # Use empty string as key part for None values
  209 + key_values.append(str(dimension_value) if dimension_value is not None else '')
  210 +
  211 + key = tuple(key_values)
  212 +
  213 + # Keep first SKU for each dimension combination
209 if key not in dimension_groups: 214 if key not in dimension_groups:
210 dimension_groups[key] = sku 215 dimension_groups[key] = sku
211 -  
212 - # Return filtered SKUs (one per dimension value) 216 +
  217 + # Return filtered SKUs (one per dimension combination)
213 return list(dimension_groups.values()) 218 return list(dimension_groups.values())
214 219
215 @staticmethod 220 @staticmethod
docs/搜索API对接指南.md
@@ -104,7 +104,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -104,7 +104,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
104 "sort_by": "string", 104 "sort_by": "string",
105 "sort_order": "desc", 105 "sort_order": "desc",
106 "min_score": 0.0, 106 "min_score": 0.0,
107 - "sku_filter_dimension": "string", 107 + "sku_filter_dimension": ["string"],
108 "debug": false, 108 "debug": false,
109 "user_id": "string", 109 "user_id": "string",
110 "session_id": "string" 110 "session_id": "string"
@@ -127,7 +127,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -127,7 +127,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
127 | `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) | 127 | `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) |
128 | `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) | 128 | `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) |
129 | `min_score` | float | N | null | 最小相关性分数阈值 | 129 | `min_score` | float | N | null | 最小相关性分数阈值 |
130 -| `sku_filter_dimension` | string | N | null | 子SKU筛选维度(店铺配置)。指定后,每个SPU下的SKU将按该维度分组,每组选择第一个SKU返回。支持的值:`option1`、`option2`、`option3` 或 specifications 中的 name(如 `color`、`size`)。详见下文说明 | 130 +| `sku_filter_dimension` | array[string] | N | null | 子SKU筛选维度列表(店铺配置)。指定后,每个SPU下的SKU将按这些维度的组合进行分组,每个组合只返回第一个SKU。支持的值:`option1`、`option2`、`option3` 或选项名称(如 `color`、`size`)。详见下文说明 |
131 | `debug` | boolean | N | false | 是否返回调试信息 | 131 | `debug` | boolean | N | false | 是否返回调试信息 |
132 | `user_id` | string | N | null | 用户ID(用于个性化,预留) | 132 | `user_id` | string | N | null | 用户ID(用于个性化,预留) |
133 | `session_id` | string | N | null | 会话ID(用于分析,预留) | 133 | `session_id` | string | N | null | 会话ID(用于分析,预留) |
@@ -349,7 +349,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -349,7 +349,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
349 ### SKU筛选维度 (sku_filter_dimension) 349 ### SKU筛选维度 (sku_filter_dimension)
350 350
351 **功能说明**: 351 **功能说明**:
352 -`sku_filter_dimension` 用于控制每个SPU下返回的SKU数量。当指定此参数后,系统会按指定维度对SKU进行分组,每个分组只返回第一个SKU(从简实现,选择该维度下的第一款)。 352 +`sku_filter_dimension` 用于控制每个SPU下返回的SKU数量,为字符串列表。当指定此参数后,系统会按指定维度**组合**对SKU进行分组,每个维度组合只返回第一个SKU(从简实现,选择该组合下的第一款)。
353 353
354 **使用场景**: 354 **使用场景**:
355 - 店铺配置了SKU筛选维度(如 `color`),希望每个SPU下每种颜色只显示一个SKU 355 - 店铺配置了SKU筛选维度(如 `color`),希望每个SPU下每种颜色只显示一个SKU
@@ -360,8 +360,8 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -360,8 +360,8 @@ curl -X POST "http://120.76.41.98:6002/search/" \
360 1. **直接选项字段**: `option1`、`option2`、`option3` 360 1. **直接选项字段**: `option1`、`option2`、`option3`
361 - 直接使用对应的 `option1_value`、`option2_value`、`option3_value` 字段进行分组 361 - 直接使用对应的 `option1_value`、`option2_value`、`option3_value` 字段进行分组
362 362
363 -2. **规格名称**: 通过 `option1_name`、`option2_name`、`option3_name` 匹配  
364 - - 例如:如果 `option1_name` 为 `"color"`,则可以使用 `sku_filter_dimension: "color"` 来按颜色分组 363 +2. **规格/选项名称**: 通过 `option1_name`、`option2_name`、`option3_name` 匹配
  364 + - 例如:如果 `option1_name` 为 `"color"`,则可以使用 `sku_filter_dimension: ["color"]` 来按颜色分组
365 365
366 **示例**: 366 **示例**:
367 367
@@ -369,7 +369,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -369,7 +369,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
369 ```json 369 ```json
370 { 370 {
371 "query": "芭比娃娃", 371 "query": "芭比娃娃",
372 - "sku_filter_dimension": "color" 372 + "sku_filter_dimension": ["color"]
373 } 373 }
374 ``` 374 ```
375 375
@@ -377,7 +377,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -377,7 +377,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
377 ```json 377 ```json
378 { 378 {
379 "query": "芭比娃娃", 379 "query": "芭比娃娃",
380 - "sku_filter_dimension": "option1" 380 + "sku_filter_dimension": ["option1"]
381 } 381 }
382 ``` 382 ```
383 383
@@ -385,7 +385,15 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -385,7 +385,15 @@ curl -X POST "http://120.76.41.98:6002/search/" \
385 ```json 385 ```json
386 { 386 {
387 "query": "芭比娃娃", 387 "query": "芭比娃娃",
388 - "sku_filter_dimension": "option2" 388 + "sku_filter_dimension": ["option2"]
  389 +}
  390 +```
  391 +
  392 +**按颜色 + 尺寸组合筛选(假设 option1_name = "color", option2_name = "size")**:
  393 +```json
  394 +{
  395 + "query": "芭比娃娃",
  396 + "sku_filter_dimension": ["color", "size"]
389 } 397 }
390 ``` 398 ```
391 399
frontend/index.html
@@ -135,6 +135,6 @@ @@ -135,6 +135,6 @@
135 <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p> 135 <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p>
136 </footer> 136 </footer>
137 137
138 - <script src="/static/js/app.js?v=3.3"></script> 138 + <script src="/static/js/app.js?v=3.4"></script>
139 </body> 139 </body>
140 </html> 140 </html>
frontend/static/js/app.js
@@ -14,13 +14,17 @@ function getTenantId() { @@ -14,13 +14,17 @@ function getTenantId() {
14 return '1'; // Default fallback 14 return '1'; // Default fallback
15 } 15 }
16 16
17 -// Get sku_filter_dimension from input 17 +// Get sku_filter_dimension (as list) from input
18 function getSkuFilterDimension() { 18 function getSkuFilterDimension() {
19 const skuFilterInput = document.getElementById('skuFilterDimension'); 19 const skuFilterInput = document.getElementById('skuFilterDimension');
20 if (skuFilterInput) { 20 if (skuFilterInput) {
21 const value = skuFilterInput.value.trim(); 21 const value = skuFilterInput.value.trim();
22 - // Return the value if not empty, otherwise return null  
23 - return value.length > 0 ? value : null; 22 + if (!value.length) {
  23 + return null;
  24 + }
  25 + // 支持用逗号分隔多个维度,例如:color,size 或 option1,color
  26 + const parts = value.split(',').map(v => v.trim()).filter(v => v.length > 0);
  27 + return parts.length > 0 ? parts : null;
24 } 28 }
25 return null; 29 return null;
26 } 30 }
search/searcher.py
@@ -130,7 +130,7 @@ class Searcher: @@ -130,7 +130,7 @@ class Searcher:
130 sort_order: Optional[str] = "desc", 130 sort_order: Optional[str] = "desc",
131 debug: bool = False, 131 debug: bool = False,
132 language: str = "zh", 132 language: str = "zh",
133 - sku_filter_dimension: Optional[str] = None, 133 + sku_filter_dimension: Optional[List[str]] = None,
134 ) -> SearchResult: 134 ) -> SearchResult:
135 """ 135 """
136 Execute search query (外部友好格式). 136 Execute search query (外部友好格式).