Commit a3a5d41b84486128bff6d2d39d8c7eb365258040
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"] }
Showing
6 changed files
with
90 additions
and
68 deletions
Show diff stats
api/models.py
| ... | ... | @@ -146,9 +146,14 @@ class SearchRequest(BaseModel): |
| 146 | 146 | debug: bool = Field(False, description="是否返回调试信息") |
| 147 | 147 | |
| 148 | 148 | # SKU筛选参数 |
| 149 | - sku_filter_dimension: Optional[str] = Field( | |
| 149 | + sku_filter_dimension: Optional[List[str]] = Field( | |
| 150 | 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 | 14 | es_hits: List[Dict[str, Any]], |
| 15 | 15 | max_score: float = 1.0, |
| 16 | 16 | language: str = "zh", |
| 17 | - sku_filter_dimension: Optional[str] = None | |
| 17 | + sku_filter_dimension: Optional[List[str]] = None | |
| 18 | 18 | ) -> List[SpuResult]: |
| 19 | 19 | """ |
| 20 | 20 | Convert ES hits to SpuResult list. |
| ... | ... | @@ -85,10 +85,10 @@ class ResultFormatter: |
| 85 | 85 | ) |
| 86 | 86 | skus.append(sku) |
| 87 | 87 | |
| 88 | - # Apply SKU filtering if dimension is specified | |
| 88 | + # Apply SKU filtering if dimension list is specified | |
| 89 | 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 | 92 | sku_filter_dimension, |
| 93 | 93 | source.get('option1_name'), |
| 94 | 94 | source.get('option2_name'), |
| ... | ... | @@ -138,22 +138,22 @@ class ResultFormatter: |
| 138 | 138 | return results |
| 139 | 139 | |
| 140 | 140 | @staticmethod |
| 141 | - def _filter_skus_by_dimension( | |
| 141 | + def _filter_skus_by_dimensions( | |
| 142 | 142 | skus: List[SkuResult], |
| 143 | - dimension: str, | |
| 143 | + dimensions: List[str], | |
| 144 | 144 | option1_name: Optional[str] = None, |
| 145 | 145 | option2_name: Optional[str] = None, |
| 146 | 146 | option3_name: Optional[str] = None, |
| 147 | 147 | specifications: Optional[List[Dict[str, Any]]] = None |
| 148 | 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 | 152 | Args: |
| 153 | 153 | skus: List of SKU results to filter |
| 154 | - dimension: Filter dimension, can be: | |
| 154 | + dimensions: Filter dimensions, each dimension can be: | |
| 155 | 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 | 157 | option1_name: Name of option1 (e.g., 'color') |
| 158 | 158 | option2_name: Name of option2 (e.g., 'size') |
| 159 | 159 | option3_name: Name of option3 |
| ... | ... | @@ -162,54 +162,59 @@ class ResultFormatter: |
| 162 | 162 | Returns: |
| 163 | 163 | Filtered list of SKUs (one per dimension value) |
| 164 | 164 | """ |
| 165 | - if not skus: | |
| 165 | + if not skus or not dimensions: | |
| 166 | 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 | 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 | 214 | if key not in dimension_groups: |
| 210 | 215 | dimension_groups[key] = sku |
| 211 | - | |
| 212 | - # Return filtered SKUs (one per dimension value) | |
| 216 | + | |
| 217 | + # Return filtered SKUs (one per dimension combination) | |
| 213 | 218 | return list(dimension_groups.values()) |
| 214 | 219 | |
| 215 | 220 | @staticmethod | ... | ... |
docs/搜索API对接指南.md
| ... | ... | @@ -104,7 +104,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 104 | 104 | "sort_by": "string", |
| 105 | 105 | "sort_order": "desc", |
| 106 | 106 | "min_score": 0.0, |
| 107 | - "sku_filter_dimension": "string", | |
| 107 | + "sku_filter_dimension": ["string"], | |
| 108 | 108 | "debug": false, |
| 109 | 109 | "user_id": "string", |
| 110 | 110 | "session_id": "string" |
| ... | ... | @@ -127,7 +127,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 127 | 127 | | `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) | |
| 128 | 128 | | `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) | |
| 129 | 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 | 131 | | `debug` | boolean | N | false | 是否返回调试信息 | |
| 132 | 132 | | `user_id` | string | N | null | 用户ID(用于个性化,预留) | |
| 133 | 133 | | `session_id` | string | N | null | 会话ID(用于分析,预留) | |
| ... | ... | @@ -349,7 +349,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 349 | 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 | 355 | - 店铺配置了SKU筛选维度(如 `color`),希望每个SPU下每种颜色只显示一个SKU |
| ... | ... | @@ -360,8 +360,8 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 360 | 360 | 1. **直接选项字段**: `option1`、`option2`、`option3` |
| 361 | 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 | 369 | ```json |
| 370 | 370 | { |
| 371 | 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 | 377 | ```json |
| 378 | 378 | { |
| 379 | 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 | 385 | ```json |
| 386 | 386 | { |
| 387 | 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
frontend/static/js/app.js
| ... | ... | @@ -14,13 +14,17 @@ function getTenantId() { |
| 14 | 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 | 18 | function getSkuFilterDimension() { |
| 19 | 19 | const skuFilterInput = document.getElementById('skuFilterDimension'); |
| 20 | 20 | if (skuFilterInput) { |
| 21 | 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 | 29 | return null; |
| 26 | 30 | } | ... | ... |
search/searcher.py
| ... | ... | @@ -130,7 +130,7 @@ class Searcher: |
| 130 | 130 | sort_order: Optional[str] = "desc", |
| 131 | 131 | debug: bool = False, |
| 132 | 132 | language: str = "zh", |
| 133 | - sku_filter_dimension: Optional[str] = None, | |
| 133 | + sku_filter_dimension: Optional[List[str]] = None, | |
| 134 | 134 | ) -> SearchResult: |
| 135 | 135 | """ |
| 136 | 136 | Execute search query (外部友好格式). | ... | ... |