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"]    }
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
... ... @@ -135,6 +135,6 @@
135 135 <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p>
136 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 139 </body>
140 140 </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 (外部友好格式).
... ...