Commit c86c8237c01f306edbc5fad41dc397ddd47c9012
1 parent
167a0cb2
支持聚合。过滤项补充了逻辑,但是有问题
Showing
16 changed files
with
1976 additions
and
35 deletions
Show diff stats
api/models.py
| @@ -13,6 +13,10 @@ class SearchRequest(BaseModel): | @@ -13,6 +13,10 @@ class SearchRequest(BaseModel): | ||
| 13 | from_: int = Field(0, ge=0, alias="from", description="Offset for pagination") | 13 | from_: int = Field(0, ge=0, alias="from", description="Offset for pagination") |
| 14 | filters: Optional[Dict[str, Any]] = Field(None, description="Additional filters") | 14 | filters: Optional[Dict[str, Any]] = Field(None, description="Additional filters") |
| 15 | min_score: Optional[float] = Field(None, description="Minimum score threshold") | 15 | min_score: Optional[float] = Field(None, description="Minimum score threshold") |
| 16 | + # 新增字段 | ||
| 17 | + aggregations: Optional[Dict[str, Any]] = Field(None, description="Aggregation specifications") | ||
| 18 | + sort_by: Optional[str] = Field(None, description="Sort field name") | ||
| 19 | + sort_order: Optional[str] = Field("desc", description="Sort order: 'asc' or 'desc'") | ||
| 16 | 20 | ||
| 17 | 21 | ||
| 18 | class ImageSearchRequest(BaseModel): | 22 | class ImageSearchRequest(BaseModel): |
api/routes/search.py
| @@ -68,7 +68,10 @@ async def search(request: SearchRequest, http_request: Request): | @@ -68,7 +68,10 @@ async def search(request: SearchRequest, http_request: Request): | ||
| 68 | from_=request.from_, | 68 | from_=request.from_, |
| 69 | filters=request.filters, | 69 | filters=request.filters, |
| 70 | min_score=request.min_score, | 70 | min_score=request.min_score, |
| 71 | - context=context | 71 | + context=context, |
| 72 | + aggregations=request.aggregations, | ||
| 73 | + sort_by=request.sort_by, | ||
| 74 | + sort_order=request.sort_order | ||
| 72 | ) | 75 | ) |
| 73 | 76 | ||
| 74 | # Include performance summary in response | 77 | # Include performance summary in response |
config/schema/customer1_config.yaml
| @@ -115,6 +115,21 @@ fields: | @@ -115,6 +115,21 @@ fields: | ||
| 115 | index: true | 115 | index: true |
| 116 | store: false | 116 | store: false |
| 117 | 117 | ||
| 118 | + - name: "supplierName_keyword" | ||
| 119 | + type: "KEYWORD" | ||
| 120 | + source_table: "extension" | ||
| 121 | + source_column: "supplierName" | ||
| 122 | + index: true | ||
| 123 | + store: false | ||
| 124 | + | ||
| 125 | + # Price Fields | ||
| 126 | + - name: "price" | ||
| 127 | + type: "DOUBLE" | ||
| 128 | + source_table: "extension" | ||
| 129 | + source_column: "price" | ||
| 130 | + index: true | ||
| 131 | + store: true | ||
| 132 | + | ||
| 118 | # Text Embedding Fields | 133 | # Text Embedding Fields |
| 119 | - name: "name_embedding" | 134 | - name: "name_embedding" |
| 120 | type: "TEXT_EMBEDDING" | 135 | type: "TEXT_EMBEDDING" |
| @@ -0,0 +1,89 @@ | @@ -0,0 +1,89 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +Debug script to check the generated ES query for sorting | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import requests | ||
| 7 | +import json | ||
| 8 | + | ||
| 9 | +def test_simple_sort(): | ||
| 10 | + """Test simple sort functionality via API""" | ||
| 11 | + | ||
| 12 | + print("Testing simple sort via API...") | ||
| 13 | + | ||
| 14 | + # Test without sort first | ||
| 15 | + request_no_sort = { | ||
| 16 | + "query": "芭比", | ||
| 17 | + "size": 1 | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + print("\n1. Search without sort:") | ||
| 21 | + try: | ||
| 22 | + response = requests.post("http://120.76.41.98:6002/search/", json=request_no_sort) | ||
| 23 | + print(f"Status: {response.status_code}") | ||
| 24 | + if response.ok: | ||
| 25 | + data = response.json() | ||
| 26 | + print(f"Results: {data.get('total', 0)} found in {data.get('took_ms', 0)}ms") | ||
| 27 | + else: | ||
| 28 | + print(f"Error: {response.text}") | ||
| 29 | + except Exception as e: | ||
| 30 | + print(f"Exception: {e}") | ||
| 31 | + | ||
| 32 | + # Test with sort | ||
| 33 | + request_with_sort = { | ||
| 34 | + "query": "芭比", | ||
| 35 | + "size": 1, | ||
| 36 | + "sort_by": "create_time", | ||
| 37 | + "sort_order": "desc" | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + print("\n2. Search with sort:") | ||
| 41 | + print(f"Request: {json.dumps(request_with_sort, ensure_ascii=False, indent=2)}") | ||
| 42 | + | ||
| 43 | + try: | ||
| 44 | + response = requests.post("http://120.76.41.98:6002/search/", json=request_with_sort) | ||
| 45 | + print(f"Status: {response.status_code}") | ||
| 46 | + if response.ok: | ||
| 47 | + data = response.json() | ||
| 48 | + print(f"Results: {data.get('total', 0)} found in {data.get('took_ms', 0)}ms") | ||
| 49 | + | ||
| 50 | + if 'hits' in data and data['hits']: | ||
| 51 | + hit = data['hits'][0] | ||
| 52 | + source = hit.get('_source', {}) | ||
| 53 | + print(f"Sample result: {source.get('name', 'N/A')}") | ||
| 54 | + print(f"Create time: {source.get('create_time', 'N/A')}") | ||
| 55 | + else: | ||
| 56 | + print(f"Error: {response.text}") | ||
| 57 | + except Exception as e: | ||
| 58 | + print(f"Exception: {e}") | ||
| 59 | + | ||
| 60 | + # Test with different sort field | ||
| 61 | + request_price_sort = { | ||
| 62 | + "query": "芭比", | ||
| 63 | + "size": 1, | ||
| 64 | + "sort_by": "price", | ||
| 65 | + "sort_order": "asc" | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + print("\n3. Search with price sort:") | ||
| 69 | + print(f"Request: {json.dumps(request_price_sort, ensure_ascii=False, indent=2)}") | ||
| 70 | + | ||
| 71 | + try: | ||
| 72 | + response = requests.post("http://120.76.41.98:6002/search/", json=request_price_sort) | ||
| 73 | + print(f"Status: {response.status_code}") | ||
| 74 | + if response.ok: | ||
| 75 | + data = response.json() | ||
| 76 | + print(f"Results: {data.get('total', 0)} found in {data.get('took_ms', 0)}ms") | ||
| 77 | + | ||
| 78 | + if 'hits' in data and data['hits']: | ||
| 79 | + hit = data['hits'][0] | ||
| 80 | + source = hit.get('_source', {}) | ||
| 81 | + print(f"Sample result: {source.get('name', 'N/A')}") | ||
| 82 | + print(f"Price: {source.get('price', 'N/A')}") | ||
| 83 | + else: | ||
| 84 | + print(f"Error: {response.text}") | ||
| 85 | + except Exception as e: | ||
| 86 | + print(f"Exception: {e}") | ||
| 87 | + | ||
| 88 | +if __name__ == "__main__": | ||
| 89 | + test_simple_sort() | ||
| 0 | \ No newline at end of file | 90 | \ No newline at end of file |
frontend/index.html
| @@ -21,14 +21,18 @@ | @@ -21,14 +21,18 @@ | ||
| 21 | </div> | 21 | </div> |
| 22 | 22 | ||
| 23 | <div class="search-options"> | 23 | <div class="search-options"> |
| 24 | - <label><input type="checkbox" id="enableTranslation" checked> 启用翻译</label> | ||
| 25 | - <label><input type="checkbox" id="enableEmbedding" checked> 启用语义搜索</label> | ||
| 26 | - <label><input type="checkbox" id="enableRerank" checked> 启用自定义排序</label> | ||
| 27 | <select id="resultSize"> | 24 | <select id="resultSize"> |
| 28 | <option value="10">10条结果</option> | 25 | <option value="10">10条结果</option> |
| 29 | <option value="20">20条结果</option> | 26 | <option value="20">20条结果</option> |
| 30 | <option value="50">50条结果</option> | 27 | <option value="50">50条结果</option> |
| 31 | </select> | 28 | </select> |
| 29 | + <select id="sortBy"> | ||
| 30 | + <option value="">默认排序</option> | ||
| 31 | + <option value="create_time:desc">上架时间(新到旧)</option> | ||
| 32 | + <option value="create_time:asc">上架时间(旧到新)</option> | ||
| 33 | + <option value="price:asc">价格(低到高)</option> | ||
| 34 | + <option value="price:desc">价格(高到低)</option> | ||
| 35 | + </select> | ||
| 32 | </div> | 36 | </div> |
| 33 | 37 | ||
| 34 | <div class="search-examples"> | 38 | <div class="search-examples"> |
| @@ -45,9 +49,19 @@ | @@ -45,9 +49,19 @@ | ||
| 45 | <p>搜索中...</p> | 49 | <p>搜索中...</p> |
| 46 | </div> | 50 | </div> |
| 47 | 51 | ||
| 48 | - <div id="results" class="results-section"></div> | 52 | + <div class="content-wrapper"> |
| 53 | + <div id="aggregationPanel" class="aggregation-panel" style="display: none;"> | ||
| 54 | + <h3>筛选条件</h3> | ||
| 55 | + <div id="activeFilters" class="active-filters"></div> | ||
| 56 | + <div id="aggregationResults" class="aggregation-results"></div> | ||
| 57 | + </div> | ||
| 58 | + | ||
| 59 | + <div class="main-content"> | ||
| 60 | + <div id="results" class="results-section"></div> | ||
| 49 | 61 | ||
| 50 | - <div id="queryInfo" class="query-info"></div> | 62 | + <div id="queryInfo" class="query-info"></div> |
| 63 | + </div> | ||
| 64 | + </div> | ||
| 51 | </div> | 65 | </div> |
| 52 | 66 | ||
| 53 | <footer> | 67 | <footer> |
frontend/static/css/style.css
| @@ -280,3 +280,153 @@ footer { | @@ -280,3 +280,153 @@ footer { | ||
| 280 | font-size: 24px; | 280 | font-size: 24px; |
| 281 | margin-bottom: 10px; | 281 | margin-bottom: 10px; |
| 282 | } | 282 | } |
| 283 | + | ||
| 284 | +/* Layout for aggregation and main content */ | ||
| 285 | +.content-wrapper { | ||
| 286 | + display: flex; | ||
| 287 | + gap: 20px; | ||
| 288 | + align-items: flex-start; | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +/* Aggregation Panel */ | ||
| 292 | +.aggregation-panel { | ||
| 293 | + background: white; | ||
| 294 | + border-radius: 15px; | ||
| 295 | + padding: 20px; | ||
| 296 | + box-shadow: 0 10px 30px rgba(0,0,0,0.1); | ||
| 297 | + width: 300px; | ||
| 298 | + flex-shrink: 0; | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +.aggregation-panel h3 { | ||
| 302 | + color: #333; | ||
| 303 | + margin-bottom: 20px; | ||
| 304 | + font-size: 1.3em; | ||
| 305 | + border-bottom: 2px solid #667eea; | ||
| 306 | + padding-bottom: 10px; | ||
| 307 | +} | ||
| 308 | + | ||
| 309 | +/* Active Filters */ | ||
| 310 | +.active-filters-list { | ||
| 311 | + margin-bottom: 20px; | ||
| 312 | + display: flex; | ||
| 313 | + flex-wrap: wrap; | ||
| 314 | + gap: 8px; | ||
| 315 | + align-items: center; | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +.active-filter-tag { | ||
| 319 | + background: #667eea; | ||
| 320 | + color: white; | ||
| 321 | + padding: 4px 8px; | ||
| 322 | + border-radius: 15px; | ||
| 323 | + font-size: 12px; | ||
| 324 | + display: flex; | ||
| 325 | + align-items: center; | ||
| 326 | + gap: 5px; | ||
| 327 | +} | ||
| 328 | + | ||
| 329 | +.remove-filter { | ||
| 330 | + background: none; | ||
| 331 | + border: none; | ||
| 332 | + color: white; | ||
| 333 | + cursor: pointer; | ||
| 334 | + font-size: 14px; | ||
| 335 | + font-weight: bold; | ||
| 336 | + padding: 0; | ||
| 337 | + width: 16px; | ||
| 338 | + height: 16px; | ||
| 339 | + border-radius: 50%; | ||
| 340 | + display: flex; | ||
| 341 | + align-items: center; | ||
| 342 | + justify-content: center; | ||
| 343 | +} | ||
| 344 | + | ||
| 345 | +.remove-filter:hover { | ||
| 346 | + background: rgba(255,255,255,0.2); | ||
| 347 | +} | ||
| 348 | + | ||
| 349 | +.clear-filters { | ||
| 350 | + background: #ff4444; | ||
| 351 | + color: white; | ||
| 352 | + border: none; | ||
| 353 | + padding: 4px 12px; | ||
| 354 | + border-radius: 15px; | ||
| 355 | + font-size: 12px; | ||
| 356 | + cursor: pointer; | ||
| 357 | + transition: background 0.3s; | ||
| 358 | +} | ||
| 359 | + | ||
| 360 | +.clear-filters:hover { | ||
| 361 | + background: #cc0000; | ||
| 362 | +} | ||
| 363 | + | ||
| 364 | +/* Aggregation Groups */ | ||
| 365 | +.aggregation-group { | ||
| 366 | + margin-bottom: 25px; | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +.aggregation-group h4 { | ||
| 370 | + color: #555; | ||
| 371 | + margin-bottom: 10px; | ||
| 372 | + font-size: 1.1em; | ||
| 373 | + font-weight: 600; | ||
| 374 | +} | ||
| 375 | + | ||
| 376 | +.aggregation-items { | ||
| 377 | + display: flex; | ||
| 378 | + flex-direction: column; | ||
| 379 | + gap: 8px; | ||
| 380 | +} | ||
| 381 | + | ||
| 382 | +.aggregation-item { | ||
| 383 | + display: flex; | ||
| 384 | + align-items: center; | ||
| 385 | + gap: 8px; | ||
| 386 | + cursor: pointer; | ||
| 387 | + padding: 5px; | ||
| 388 | + border-radius: 5px; | ||
| 389 | + transition: background-color 0.2s; | ||
| 390 | +} | ||
| 391 | + | ||
| 392 | +.aggregation-item:hover { | ||
| 393 | + background-color: #f5f5f5; | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +.aggregation-item input[type="checkbox"] { | ||
| 397 | + margin: 0; | ||
| 398 | +} | ||
| 399 | + | ||
| 400 | +.aggregation-item span { | ||
| 401 | + flex: 1; | ||
| 402 | + font-size: 14px; | ||
| 403 | + color: #333; | ||
| 404 | +} | ||
| 405 | + | ||
| 406 | +.aggregation-item .count { | ||
| 407 | + color: #888; | ||
| 408 | + font-size: 12px; | ||
| 409 | + font-weight: normal; | ||
| 410 | +} | ||
| 411 | + | ||
| 412 | +/* Main content area */ | ||
| 413 | +.main-content { | ||
| 414 | + flex: 1; | ||
| 415 | + min-width: 0; /* Allow content to shrink */ | ||
| 416 | +} | ||
| 417 | + | ||
| 418 | +/* Responsive design */ | ||
| 419 | +@media (max-width: 768px) { | ||
| 420 | + .content-wrapper { | ||
| 421 | + flex-direction: column; | ||
| 422 | + } | ||
| 423 | + | ||
| 424 | + .aggregation-panel { | ||
| 425 | + width: 100%; | ||
| 426 | + order: 2; /* Show below results on mobile */ | ||
| 427 | + } | ||
| 428 | + | ||
| 429 | + .main-content { | ||
| 430 | + order: 1; | ||
| 431 | + } | ||
| 432 | +} |
frontend/static/js/app.js
| @@ -19,6 +19,9 @@ function setQuery(query) { | @@ -19,6 +19,9 @@ function setQuery(query) { | ||
| 19 | performSearch(); | 19 | performSearch(); |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | +// 全局变量存储当前的过滤条件 | ||
| 23 | +let currentFilters = {}; | ||
| 24 | + | ||
| 22 | // Perform search | 25 | // Perform search |
| 23 | async function performSearch() { | 26 | async function performSearch() { |
| 24 | const query = document.getElementById('searchInput').value.trim(); | 27 | const query = document.getElementById('searchInput').value.trim(); |
| @@ -28,16 +31,57 @@ async function performSearch() { | @@ -28,16 +31,57 @@ async function performSearch() { | ||
| 28 | return; | 31 | return; |
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | - // Get options (temporarily disable translation and embedding due to GPU issues) | 34 | + // Get options |
| 32 | const size = parseInt(document.getElementById('resultSize').value); | 35 | const size = parseInt(document.getElementById('resultSize').value); |
| 33 | - const enableTranslation = false; // Disabled temporarily | ||
| 34 | - const enableEmbedding = false; // Disabled temporarily | ||
| 35 | - const enableRerank = document.getElementById('enableRerank').checked; | 36 | + const sortByValue = document.getElementById('sortBy').value; |
| 37 | + | ||
| 38 | + // Parse sort option | ||
| 39 | + let sort_by = null; | ||
| 40 | + let sort_order = 'desc'; | ||
| 41 | + if (sortByValue) { | ||
| 42 | + const [field, order] = sortByValue.split(':'); | ||
| 43 | + sort_by = field; | ||
| 44 | + sort_order = order; | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + // Define aggregations for faceted search | ||
| 48 | + const aggregations = { | ||
| 49 | + "category_stats": { | ||
| 50 | + "terms": { | ||
| 51 | + "field": "categoryName_keyword", | ||
| 52 | + "size": 10 | ||
| 53 | + } | ||
| 54 | + }, | ||
| 55 | + "brand_stats": { | ||
| 56 | + "terms": { | ||
| 57 | + "field": "brandName_keyword", | ||
| 58 | + "size": 10 | ||
| 59 | + } | ||
| 60 | + }, | ||
| 61 | + "supplier_stats": { | ||
| 62 | + "terms": { | ||
| 63 | + "field": "supplierName_keyword", | ||
| 64 | + "size": 10 | ||
| 65 | + } | ||
| 66 | + }, | ||
| 67 | + "price_ranges": { | ||
| 68 | + "range": { | ||
| 69 | + "field": "price", | ||
| 70 | + "ranges": [ | ||
| 71 | + {"key": "0-50", "to": 50}, | ||
| 72 | + {"key": "50-100", "from": 50, "to": 100}, | ||
| 73 | + {"key": "100-200", "from": 100, "to": 200}, | ||
| 74 | + {"key": "200+", "from": 200} | ||
| 75 | + ] | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + }; | ||
| 36 | 79 | ||
| 37 | // Show loading | 80 | // Show loading |
| 38 | document.getElementById('loading').style.display = 'block'; | 81 | document.getElementById('loading').style.display = 'block'; |
| 39 | document.getElementById('results').innerHTML = ''; | 82 | document.getElementById('results').innerHTML = ''; |
| 40 | document.getElementById('queryInfo').innerHTML = ''; | 83 | document.getElementById('queryInfo').innerHTML = ''; |
| 84 | + document.getElementById('aggregationResults').innerHTML = ''; | ||
| 41 | 85 | ||
| 42 | try { | 86 | try { |
| 43 | const response = await fetch(`${API_BASE_URL}/search/`, { | 87 | const response = await fetch(`${API_BASE_URL}/search/`, { |
| @@ -48,9 +92,10 @@ async function performSearch() { | @@ -48,9 +92,10 @@ async function performSearch() { | ||
| 48 | body: JSON.stringify({ | 92 | body: JSON.stringify({ |
| 49 | query: query, | 93 | query: query, |
| 50 | size: size, | 94 | size: size, |
| 51 | - enable_translation: enableTranslation, | ||
| 52 | - enable_embedding: enableEmbedding, | ||
| 53 | - enable_rerank: enableRerank | 95 | + filters: Object.keys(currentFilters).length > 0 ? currentFilters : null, |
| 96 | + aggregations: aggregations, | ||
| 97 | + sort_by: sort_by, | ||
| 98 | + sort_order: sort_order | ||
| 54 | }) | 99 | }) |
| 55 | }); | 100 | }); |
| 56 | 101 | ||
| @@ -61,6 +106,8 @@ async function performSearch() { | @@ -61,6 +106,8 @@ async function performSearch() { | ||
| 61 | const data = await response.json(); | 106 | const data = await response.json(); |
| 62 | displayResults(data); | 107 | displayResults(data); |
| 63 | displayQueryInfo(data.query_info); | 108 | displayQueryInfo(data.query_info); |
| 109 | + displayAggregations(data.aggregations); | ||
| 110 | + displayActiveFilters(); | ||
| 64 | 111 | ||
| 65 | } catch (error) { | 112 | } catch (error) { |
| 66 | console.error('Search error:', error); | 113 | console.error('Search error:', error); |
| @@ -119,6 +166,7 @@ function displayResults(data) { | @@ -119,6 +166,7 @@ function displayResults(data) { | ||
| 119 | </div> | 166 | </div> |
| 120 | 167 | ||
| 121 | <div class="result-meta"> | 168 | <div class="result-meta"> |
| 169 | + ${source.price ? `<span>💰 ¥${escapeHtml(source.price)}</span>` : ''} | ||
| 122 | ${source.categoryName ? `<span>📁 ${escapeHtml(source.categoryName)}</span>` : ''} | 170 | ${source.categoryName ? `<span>📁 ${escapeHtml(source.categoryName)}</span>` : ''} |
| 123 | ${source.brandName ? `<span>🏷️ ${escapeHtml(source.brandName)}</span>` : ''} | 171 | ${source.brandName ? `<span>🏷️ ${escapeHtml(source.brandName)}</span>` : ''} |
| 124 | ${source.supplierName ? `<span>🏭 ${escapeHtml(source.supplierName)}</span>` : ''} | 172 | ${source.supplierName ? `<span>🏭 ${escapeHtml(source.supplierName)}</span>` : ''} |
| @@ -227,6 +275,236 @@ function getLanguageName(code) { | @@ -227,6 +275,236 @@ function getLanguageName(code) { | ||
| 227 | return names[code] || code; | 275 | return names[code] || code; |
| 228 | } | 276 | } |
| 229 | 277 | ||
| 278 | +// Display aggregations | ||
| 279 | +function displayAggregations(aggregations) { | ||
| 280 | + if (!aggregations || Object.keys(aggregations).length === 0) { | ||
| 281 | + document.getElementById('aggregationPanel').style.display = 'none'; | ||
| 282 | + return; | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + document.getElementById('aggregationPanel').style.display = 'block'; | ||
| 286 | + const aggregationResultsDiv = document.getElementById('aggregationResults'); | ||
| 287 | + | ||
| 288 | + let html = ''; | ||
| 289 | + | ||
| 290 | + // Category aggregation | ||
| 291 | + if (aggregations.category_stats && aggregations.category_stats.buckets) { | ||
| 292 | + html += ` | ||
| 293 | + <div class="aggregation-group"> | ||
| 294 | + <h4>商品分类</h4> | ||
| 295 | + <div class="aggregation-items"> | ||
| 296 | + `; | ||
| 297 | + | ||
| 298 | + aggregations.category_stats.buckets.forEach(bucket => { | ||
| 299 | + const key = bucket.key; | ||
| 300 | + const count = bucket.doc_count; | ||
| 301 | + const isChecked = currentFilters.categoryName_keyword && currentFilters.categoryName_keyword.includes(key); | ||
| 302 | + | ||
| 303 | + html += ` | ||
| 304 | + <label class="aggregation-item"> | ||
| 305 | + <input type="checkbox" | ||
| 306 | + ${isChecked ? 'checked' : ''} | ||
| 307 | + onchange="toggleFilter('categoryName_keyword', '${escapeHtml(key)}', this.checked)"> | ||
| 308 | + <span>${escapeHtml(key)}</span> | ||
| 309 | + <span class="count">(${count})</span> | ||
| 310 | + </label> | ||
| 311 | + `; | ||
| 312 | + }); | ||
| 313 | + | ||
| 314 | + html += '</div></div>'; | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + // Brand aggregation | ||
| 318 | + if (aggregations.brand_stats && aggregations.brand_stats.buckets) { | ||
| 319 | + html += ` | ||
| 320 | + <div class="aggregation-group"> | ||
| 321 | + <h4>品牌</h4> | ||
| 322 | + <div class="aggregation-items"> | ||
| 323 | + `; | ||
| 324 | + | ||
| 325 | + aggregations.brand_stats.buckets.forEach(bucket => { | ||
| 326 | + const key = bucket.key; | ||
| 327 | + const count = bucket.doc_count; | ||
| 328 | + const isChecked = currentFilters.brandName_keyword && currentFilters.brandName_keyword.includes(key); | ||
| 329 | + | ||
| 330 | + html += ` | ||
| 331 | + <label class="aggregation-item"> | ||
| 332 | + <input type="checkbox" | ||
| 333 | + ${isChecked ? 'checked' : ''} | ||
| 334 | + onchange="toggleFilter('brandName_keyword', '${escapeHtml(key)}', this.checked)"> | ||
| 335 | + <span>${escapeHtml(key)}</span> | ||
| 336 | + <span class="count">(${count})</span> | ||
| 337 | + </label> | ||
| 338 | + `; | ||
| 339 | + }); | ||
| 340 | + | ||
| 341 | + html += '</div></div>'; | ||
| 342 | + } | ||
| 343 | + | ||
| 344 | + // Supplier aggregation | ||
| 345 | + if (aggregations.supplier_stats && aggregations.supplier_stats.buckets) { | ||
| 346 | + html += ` | ||
| 347 | + <div class="aggregation-group"> | ||
| 348 | + <h4>供应商</h4> | ||
| 349 | + <div class="aggregation-items"> | ||
| 350 | + `; | ||
| 351 | + | ||
| 352 | + aggregations.supplier_stats.buckets.slice(0, 5).forEach(bucket => { | ||
| 353 | + const key = bucket.key; | ||
| 354 | + const count = bucket.doc_count; | ||
| 355 | + const isChecked = currentFilters.supplierName_keyword && currentFilters.supplierName_keyword.includes(key); | ||
| 356 | + | ||
| 357 | + html += ` | ||
| 358 | + <label class="aggregation-item"> | ||
| 359 | + <input type="checkbox" | ||
| 360 | + ${isChecked ? 'checked' : ''} | ||
| 361 | + onchange="toggleFilter('supplierName_keyword', '${escapeHtml(key)}', this.checked)"> | ||
| 362 | + <span>${escapeHtml(key)}</span> | ||
| 363 | + <span class="count">(${count})</span> | ||
| 364 | + </label> | ||
| 365 | + `; | ||
| 366 | + }); | ||
| 367 | + | ||
| 368 | + html += '</div></div>'; | ||
| 369 | + } | ||
| 370 | + | ||
| 371 | + // Price range aggregation | ||
| 372 | + if (aggregations.price_ranges && aggregations.price_ranges.buckets) { | ||
| 373 | + html += ` | ||
| 374 | + <div class="aggregation-group"> | ||
| 375 | + <h4>价格区间</h4> | ||
| 376 | + <div class="aggregation-items"> | ||
| 377 | + `; | ||
| 378 | + | ||
| 379 | + aggregations.price_ranges.buckets.forEach(bucket => { | ||
| 380 | + const key = bucket.key; | ||
| 381 | + const count = bucket.doc_count; | ||
| 382 | + const isChecked = currentFilters.price_ranges && currentFilters.price_ranges.includes(key); | ||
| 383 | + | ||
| 384 | + const priceLabel = { | ||
| 385 | + '0-50': '¥0-50', | ||
| 386 | + '50-100': '¥50-100', | ||
| 387 | + '100-200': '¥100-200', | ||
| 388 | + '200+': '¥200+' | ||
| 389 | + }; | ||
| 390 | + | ||
| 391 | + html += ` | ||
| 392 | + <label class="aggregation-item"> | ||
| 393 | + <input type="checkbox" | ||
| 394 | + ${isChecked ? 'checked' : ''} | ||
| 395 | + onchange="togglePriceFilter('${escapeHtml(key)}', this.checked)"> | ||
| 396 | + <span>${priceLabel[key] || key}</span> | ||
| 397 | + <span class="count">(${count})</span> | ||
| 398 | + </label> | ||
| 399 | + `; | ||
| 400 | + }); | ||
| 401 | + | ||
| 402 | + html += '</div></div>'; | ||
| 403 | + } | ||
| 404 | + | ||
| 405 | + aggregationResultsDiv.innerHTML = html; | ||
| 406 | +} | ||
| 407 | + | ||
| 408 | +// Display active filters | ||
| 409 | +function displayActiveFilters() { | ||
| 410 | + const activeFiltersDiv = document.getElementById('activeFilters'); | ||
| 411 | + | ||
| 412 | + if (Object.keys(currentFilters).length === 0) { | ||
| 413 | + activeFiltersDiv.innerHTML = ''; | ||
| 414 | + return; | ||
| 415 | + } | ||
| 416 | + | ||
| 417 | + let html = '<div class="active-filters-list">'; | ||
| 418 | + | ||
| 419 | + Object.entries(currentFilters).forEach(([field, values]) => { | ||
| 420 | + if (Array.isArray(values)) { | ||
| 421 | + values.forEach(value => { | ||
| 422 | + let displayValue = value; | ||
| 423 | + if (field === 'price_ranges') { | ||
| 424 | + const priceLabel = { | ||
| 425 | + '0-50': '¥0-50', | ||
| 426 | + '50-100': '¥50-100', | ||
| 427 | + '100-200': '¥100-200', | ||
| 428 | + '200+': '¥200+' | ||
| 429 | + }; | ||
| 430 | + displayValue = priceLabel[value] || value; | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + html += ` | ||
| 434 | + <span class="active-filter-tag"> | ||
| 435 | + ${escapeHtml(displayValue)} | ||
| 436 | + <button onclick="removeFilter('${field}', '${escapeHtml(value)}')" class="remove-filter">×</button> | ||
| 437 | + </span> | ||
| 438 | + `; | ||
| 439 | + }); | ||
| 440 | + } | ||
| 441 | + }); | ||
| 442 | + | ||
| 443 | + html += `<button onclick="clearAllFilters()" class="clear-filters">清除所有</button></div>`; | ||
| 444 | + activeFiltersDiv.innerHTML = html; | ||
| 445 | +} | ||
| 446 | + | ||
| 447 | +// Toggle filter | ||
| 448 | +function toggleFilter(field, value, checked) { | ||
| 449 | + if (checked) { | ||
| 450 | + if (!currentFilters[field]) { | ||
| 451 | + currentFilters[field] = []; | ||
| 452 | + } | ||
| 453 | + if (!currentFilters[field].includes(value)) { | ||
| 454 | + currentFilters[field].push(value); | ||
| 455 | + } | ||
| 456 | + } else { | ||
| 457 | + if (currentFilters[field]) { | ||
| 458 | + const index = currentFilters[field].indexOf(value); | ||
| 459 | + if (index > -1) { | ||
| 460 | + currentFilters[field].splice(index, 1); | ||
| 461 | + } | ||
| 462 | + if (currentFilters[field].length === 0) { | ||
| 463 | + delete currentFilters[field]; | ||
| 464 | + } | ||
| 465 | + } | ||
| 466 | + } | ||
| 467 | + | ||
| 468 | + // Re-run search with new filters | ||
| 469 | + performSearch(); | ||
| 470 | +} | ||
| 471 | + | ||
| 472 | +// Toggle price filter | ||
| 473 | +function togglePriceFilter(value, checked) { | ||
| 474 | + if (checked) { | ||
| 475 | + if (!currentFilters.price_ranges) { | ||
| 476 | + currentFilters.price_ranges = []; | ||
| 477 | + } | ||
| 478 | + if (!currentFilters.price_ranges.includes(value)) { | ||
| 479 | + currentFilters.price_ranges.push(value); | ||
| 480 | + } | ||
| 481 | + } else { | ||
| 482 | + if (currentFilters.price_ranges) { | ||
| 483 | + const index = currentFilters.price_ranges.indexOf(value); | ||
| 484 | + if (index > -1) { | ||
| 485 | + currentFilters.price_ranges.splice(index, 1); | ||
| 486 | + } | ||
| 487 | + if (currentFilters.price_ranges.length === 0) { | ||
| 488 | + delete currentFilters.price_ranges; | ||
| 489 | + } | ||
| 490 | + } | ||
| 491 | + } | ||
| 492 | + | ||
| 493 | + // Re-run search with new filters | ||
| 494 | + performSearch(); | ||
| 495 | +} | ||
| 496 | + | ||
| 497 | +// Remove single filter | ||
| 498 | +function removeFilter(field, value) { | ||
| 499 | + toggleFilter(field, value, false); | ||
| 500 | +} | ||
| 501 | + | ||
| 502 | +// Clear all filters | ||
| 503 | +function clearAllFilters() { | ||
| 504 | + currentFilters = {}; | ||
| 505 | + performSearch(); | ||
| 506 | +} | ||
| 507 | + | ||
| 230 | // Initialize page | 508 | // Initialize page |
| 231 | document.addEventListener('DOMContentLoaded', function() { | 509 | document.addEventListener('DOMContentLoaded', function() { |
| 232 | console.log('SearchEngine Frontend loaded'); | 510 | console.log('SearchEngine Frontend loaded'); |
search/es_query_builder.py
| @@ -202,7 +202,36 @@ class ESQueryBuilder: | @@ -202,7 +202,36 @@ class ESQueryBuilder: | ||
| 202 | filter_clauses = [] | 202 | filter_clauses = [] |
| 203 | 203 | ||
| 204 | for field, value in filters.items(): | 204 | for field, value in filters.items(): |
| 205 | - if isinstance(value, dict): | 205 | + if field == 'price_ranges': |
| 206 | + # Handle price range filters | ||
| 207 | + if isinstance(value, list): | ||
| 208 | + price_ranges = [] | ||
| 209 | + for price_range in value: | ||
| 210 | + if price_range == '0-50': | ||
| 211 | + price_ranges.append({"lt": 50}) | ||
| 212 | + elif price_range == '50-100': | ||
| 213 | + price_ranges.append({"gte": 50, "lt": 100}) | ||
| 214 | + elif price_range == '100-200': | ||
| 215 | + price_ranges.append({"gte": 100, "lt": 200}) | ||
| 216 | + elif price_range == '200+': | ||
| 217 | + price_ranges.append({"gte": 200}) | ||
| 218 | + | ||
| 219 | + if price_ranges: | ||
| 220 | + if len(price_ranges) == 1: | ||
| 221 | + filter_clauses.append({ | ||
| 222 | + "range": { | ||
| 223 | + "price": price_ranges[0] | ||
| 224 | + } | ||
| 225 | + }) | ||
| 226 | + else: | ||
| 227 | + # Multiple price ranges - use bool should clause | ||
| 228 | + range_clauses = [{"range": {"price": pr}} for pr in price_ranges] | ||
| 229 | + filter_clauses.append({ | ||
| 230 | + "bool": { | ||
| 231 | + "should": range_clauses | ||
| 232 | + } | ||
| 233 | + }) | ||
| 234 | + elif isinstance(value, dict): | ||
| 206 | # Range query | 235 | # Range query |
| 207 | if "gte" in value or "lte" in value or "gt" in value or "lt" in value: | 236 | if "gte" in value or "lte" in value or "gt" in value or "lt" in value: |
| 208 | filter_clauses.append({ | 237 | filter_clauses.append({ |
| @@ -266,6 +295,65 @@ class ESQueryBuilder: | @@ -266,6 +295,65 @@ class ESQueryBuilder: | ||
| 266 | 295 | ||
| 267 | return es_query | 296 | return es_query |
| 268 | 297 | ||
| 298 | + def add_dynamic_aggregations( | ||
| 299 | + self, | ||
| 300 | + es_query: Dict[str, Any], | ||
| 301 | + aggregations: Dict[str, Any] | ||
| 302 | + ) -> Dict[str, Any]: | ||
| 303 | + """ | ||
| 304 | + Add dynamic aggregations based on request parameters. | ||
| 305 | + | ||
| 306 | + Args: | ||
| 307 | + es_query: Existing ES query | ||
| 308 | + aggregations: Aggregation specifications | ||
| 309 | + | ||
| 310 | + Returns: | ||
| 311 | + Modified ES query | ||
| 312 | + """ | ||
| 313 | + if "aggs" not in es_query: | ||
| 314 | + es_query["aggs"] = {} | ||
| 315 | + | ||
| 316 | + for agg_name, agg_spec in aggregations.items(): | ||
| 317 | + es_query["aggs"][agg_name] = agg_spec | ||
| 318 | + | ||
| 319 | + return es_query | ||
| 320 | + | ||
| 321 | + def add_sorting( | ||
| 322 | + self, | ||
| 323 | + es_query: Dict[str, Any], | ||
| 324 | + sort_by: str, | ||
| 325 | + sort_order: str = "desc" | ||
| 326 | + ) -> Dict[str, Any]: | ||
| 327 | + """ | ||
| 328 | + Add sorting to ES query. | ||
| 329 | + | ||
| 330 | + Args: | ||
| 331 | + es_query: Existing ES query | ||
| 332 | + sort_by: Field name for sorting | ||
| 333 | + sort_order: Sort order: 'asc' or 'desc' | ||
| 334 | + | ||
| 335 | + Returns: | ||
| 336 | + Modified ES query | ||
| 337 | + """ | ||
| 338 | + if not sort_by: | ||
| 339 | + return es_query | ||
| 340 | + | ||
| 341 | + if not sort_order: | ||
| 342 | + sort_order = "desc" | ||
| 343 | + | ||
| 344 | + if "sort" not in es_query: | ||
| 345 | + es_query["sort"] = [] | ||
| 346 | + | ||
| 347 | + # Add the specified sort | ||
| 348 | + sort_field = { | ||
| 349 | + sort_by: { | ||
| 350 | + "order": sort_order.lower() | ||
| 351 | + } | ||
| 352 | + } | ||
| 353 | + es_query["sort"].append(sort_field) | ||
| 354 | + | ||
| 355 | + return es_query | ||
| 356 | + | ||
| 269 | def add_aggregations( | 357 | def add_aggregations( |
| 270 | self, | 358 | self, |
| 271 | es_query: Dict[str, Any], | 359 | es_query: Dict[str, Any], |
search/multilang_query_builder.py
| @@ -319,7 +319,11 @@ class MultiLanguageQueryBuilder(ESQueryBuilder): | @@ -319,7 +319,11 @@ class MultiLanguageQueryBuilder(ESQueryBuilder): | ||
| 319 | if hasattr(node, 'operator'): | 319 | if hasattr(node, 'operator'): |
| 320 | # QueryNode object | 320 | # QueryNode object |
| 321 | operator = node.operator | 321 | operator = node.operator |
| 322 | - terms = node.terms | 322 | + terms = node.terms if hasattr(node, 'terms') else None |
| 323 | + | ||
| 324 | + # For TERM nodes, check if there's a value | ||
| 325 | + if operator == 'TERM' and hasattr(node, 'value') and node.value: | ||
| 326 | + terms = node.value | ||
| 323 | elif isinstance(node, tuple) and len(node) > 0: | 327 | elif isinstance(node, tuple) and len(node) > 0: |
| 324 | # Tuple format from boolean parser | 328 | # Tuple format from boolean parser |
| 325 | if hasattr(node[0], 'operator'): | 329 | if hasattr(node[0], 'operator'): |
| @@ -353,9 +357,7 @@ class MultiLanguageQueryBuilder(ESQueryBuilder): | @@ -353,9 +357,7 @@ class MultiLanguageQueryBuilder(ESQueryBuilder): | ||
| 353 | else: | 357 | else: |
| 354 | return {"match_all": {}} | 358 | return {"match_all": {}} |
| 355 | 359 | ||
| 356 | - print(f"[MultiLangQueryBuilder] Building boolean query for operator: {operator}") | ||
| 357 | - print(f"[MultiLangQueryBuilder] Terms: {terms}") | ||
| 358 | - | 360 | + |
| 359 | if operator == 'TERM': | 361 | if operator == 'TERM': |
| 360 | # Leaf node - handle field:query format | 362 | # Leaf node - handle field:query format |
| 361 | if isinstance(terms, str) and ':' in terms: | 363 | if isinstance(terms, str) and ':' in terms: |
| @@ -365,31 +367,58 @@ class MultiLanguageQueryBuilder(ESQueryBuilder): | @@ -365,31 +367,58 @@ class MultiLanguageQueryBuilder(ESQueryBuilder): | ||
| 365 | field: value | 367 | field: value |
| 366 | } | 368 | } |
| 367 | } | 369 | } |
| 370 | + elif isinstance(terms, str): | ||
| 371 | + # Simple text term - create match query | ||
| 372 | + return { | ||
| 373 | + "multi_match": { | ||
| 374 | + "query": terms, | ||
| 375 | + "fields": self.match_fields, | ||
| 376 | + "type": "best_fields", | ||
| 377 | + "operator": "AND" | ||
| 378 | + } | ||
| 379 | + } | ||
| 368 | else: | 380 | else: |
| 369 | - return {"match_all": {}} | 381 | + # Invalid TERM node - return empty match |
| 382 | + return { | ||
| 383 | + "match_none": {} | ||
| 384 | + } | ||
| 370 | 385 | ||
| 371 | elif operator == 'OR': | 386 | elif operator == 'OR': |
| 372 | # Any term must match | 387 | # Any term must match |
| 373 | should_clauses = [] | 388 | should_clauses = [] |
| 374 | - for term in terms: | ||
| 375 | - should_clauses.append(self._build_boolean_query_from_tuple(term)) | ||
| 376 | - return { | ||
| 377 | - "bool": { | ||
| 378 | - "should": should_clauses, | ||
| 379 | - "minimum_should_match": 1 | 389 | + if terms: |
| 390 | + for term in terms: | ||
| 391 | + clause = self._build_boolean_query_from_tuple(term) | ||
| 392 | + if clause and clause.get("match_none") is None: | ||
| 393 | + should_clauses.append(clause) | ||
| 394 | + | ||
| 395 | + if should_clauses: | ||
| 396 | + return { | ||
| 397 | + "bool": { | ||
| 398 | + "should": should_clauses, | ||
| 399 | + "minimum_should_match": 1 | ||
| 400 | + } | ||
| 380 | } | 401 | } |
| 381 | - } | 402 | + else: |
| 403 | + return {"match_none": {}} | ||
| 382 | 404 | ||
| 383 | elif operator == 'AND': | 405 | elif operator == 'AND': |
| 384 | # All terms must match | 406 | # All terms must match |
| 385 | must_clauses = [] | 407 | must_clauses = [] |
| 386 | - for term in terms: | ||
| 387 | - must_clauses.append(self._build_boolean_query_from_tuple(term)) | ||
| 388 | - return { | ||
| 389 | - "bool": { | ||
| 390 | - "must": must_clauses | 408 | + if terms: |
| 409 | + for term in terms: | ||
| 410 | + clause = self._build_boolean_query_from_tuple(term) | ||
| 411 | + if clause and clause.get("match_none") is None: | ||
| 412 | + must_clauses.append(clause) | ||
| 413 | + | ||
| 414 | + if must_clauses: | ||
| 415 | + return { | ||
| 416 | + "bool": { | ||
| 417 | + "must": must_clauses | ||
| 418 | + } | ||
| 391 | } | 419 | } |
| 392 | - } | 420 | + else: |
| 421 | + return {"match_none": {}} | ||
| 393 | 422 | ||
| 394 | elif operator == 'ANDNOT': | 423 | elif operator == 'ANDNOT': |
| 395 | # First term must match, second must not | 424 | # First term must match, second must not |
search/searcher.py
| @@ -103,7 +103,10 @@ class Searcher: | @@ -103,7 +103,10 @@ class Searcher: | ||
| 103 | from_: int = 0, | 103 | from_: int = 0, |
| 104 | filters: Optional[Dict[str, Any]] = None, | 104 | filters: Optional[Dict[str, Any]] = None, |
| 105 | min_score: Optional[float] = None, | 105 | min_score: Optional[float] = None, |
| 106 | - context: Optional[RequestContext] = None | 106 | + context: Optional[RequestContext] = None, |
| 107 | + aggregations: Optional[Dict[str, Any]] = None, | ||
| 108 | + sort_by: Optional[str] = None, | ||
| 109 | + sort_order: Optional[str] = "desc" | ||
| 107 | ) -> SearchResult: | 110 | ) -> SearchResult: |
| 108 | """ | 111 | """ |
| 109 | Execute search query. | 112 | Execute search query. |
| @@ -115,6 +118,9 @@ class Searcher: | @@ -115,6 +118,9 @@ class Searcher: | ||
| 115 | filters: Additional filters (field: value pairs) | 118 | filters: Additional filters (field: value pairs) |
| 116 | min_score: Minimum score threshold | 119 | min_score: Minimum score threshold |
| 117 | context: Request context for tracking (created if not provided) | 120 | context: Request context for tracking (created if not provided) |
| 121 | + aggregations: Aggregation specifications for faceted search | ||
| 122 | + sort_by: Field name for sorting | ||
| 123 | + sort_order: Sort order: 'asc' or 'desc' | ||
| 118 | 124 | ||
| 119 | Returns: | 125 | Returns: |
| 120 | SearchResult object | 126 | SearchResult object |
| @@ -146,7 +152,10 @@ class Searcher: | @@ -146,7 +152,10 @@ class Searcher: | ||
| 146 | 'enable_translation': enable_translation, | 152 | 'enable_translation': enable_translation, |
| 147 | 'enable_embedding': enable_embedding, | 153 | 'enable_embedding': enable_embedding, |
| 148 | 'enable_rerank': enable_rerank, | 154 | 'enable_rerank': enable_rerank, |
| 149 | - 'min_score': min_score | 155 | + 'min_score': min_score, |
| 156 | + 'aggregations': aggregations, | ||
| 157 | + 'sort_by': sort_by, | ||
| 158 | + 'sort_order': sort_order | ||
| 150 | } | 159 | } |
| 151 | 160 | ||
| 152 | context.metadata['feature_flags'] = { | 161 | context.metadata['feature_flags'] = { |
| @@ -247,11 +256,19 @@ class Searcher: | @@ -247,11 +256,19 @@ class Searcher: | ||
| 247 | ) | 256 | ) |
| 248 | 257 | ||
| 249 | # Add aggregations for faceted search | 258 | # Add aggregations for faceted search |
| 250 | - if filters: | 259 | + if aggregations: |
| 260 | + # Use dynamic aggregations from request | ||
| 261 | + es_query = self.query_builder.add_dynamic_aggregations(es_query, aggregations) | ||
| 262 | + elif filters: | ||
| 263 | + # Fallback to filter-based aggregations | ||
| 251 | agg_fields = [f"{k}_keyword" for k in filters.keys() if f"{k}_keyword" in [f.name for f in self.config.fields]] | 264 | agg_fields = [f"{k}_keyword" for k in filters.keys() if f"{k}_keyword" in [f.name for f in self.config.fields]] |
| 252 | if agg_fields: | 265 | if agg_fields: |
| 253 | es_query = self.query_builder.add_aggregations(es_query, agg_fields) | 266 | es_query = self.query_builder.add_aggregations(es_query, agg_fields) |
| 254 | 267 | ||
| 268 | + # Add sorting if specified | ||
| 269 | + if sort_by: | ||
| 270 | + es_query = self.query_builder.add_sorting(es_query, sort_by, sort_order) | ||
| 271 | + | ||
| 255 | # Extract size and from from body for ES client parameters | 272 | # Extract size and from from body for ES client parameters |
| 256 | body_for_es = {k: v for k, v in es_query.items() if k not in ['size', 'from']} | 273 | body_for_es = {k: v for k, v in es_query.items() if k not in ['size', 'from']} |
| 257 | 274 |
| @@ -0,0 +1,340 @@ | @@ -0,0 +1,340 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +Simple API server for testing aggregation functionality without external dependencies. | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import json | ||
| 7 | +import time | ||
| 8 | +import random | ||
| 9 | +from http.server import HTTPServer, BaseHTTPRequestHandler | ||
| 10 | +from urllib.parse import urlparse, parse_qs | ||
| 11 | +import threading | ||
| 12 | + | ||
| 13 | +class SearchAPIHandler(BaseHTTPRequestHandler): | ||
| 14 | + """Simple API handler for search requests.""" | ||
| 15 | + | ||
| 16 | + def do_OPTIONS(self): | ||
| 17 | + """Handle CORS preflight requests.""" | ||
| 18 | + self.send_response(200) | ||
| 19 | + self.send_header('Access-Control-Allow-Origin', '*') | ||
| 20 | + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') | ||
| 21 | + self.send_header('Access-Control-Allow-Headers', 'Content-Type') | ||
| 22 | + self.end_headers() | ||
| 23 | + | ||
| 24 | + def do_POST(self): | ||
| 25 | + """Handle POST requests.""" | ||
| 26 | + if self.path == '/': | ||
| 27 | + self.handle_search() | ||
| 28 | + elif self.path == '/search/': | ||
| 29 | + self.handle_search() | ||
| 30 | + else: | ||
| 31 | + self.send_response(404) | ||
| 32 | + self.end_headers() | ||
| 33 | + | ||
| 34 | + def handle_search(self): | ||
| 35 | + """Handle search requests with aggregations.""" | ||
| 36 | + try: | ||
| 37 | + # Read request body | ||
| 38 | + content_length = int(self.headers['Content-Length']) | ||
| 39 | + post_data = self.rfile.read(content_length) | ||
| 40 | + request_data = json.loads(post_data.decode('utf-8')) | ||
| 41 | + | ||
| 42 | + query = request_data.get('query', '') | ||
| 43 | + size = request_data.get('size', 10) | ||
| 44 | + sort_by = request_data.get('sort_by', 'relevance') | ||
| 45 | + aggregations = request_data.get('aggregations', {}) | ||
| 46 | + filters = request_data.get('filters', {}) | ||
| 47 | + | ||
| 48 | + print(f"Search request: query='{query}', size={size}, sort_by={sort_by}") | ||
| 49 | + print(f"Aggregations: {list(aggregations.keys()) if aggregations else 'None'}") | ||
| 50 | + print(f"Filters: {filters if filters else 'None'}") | ||
| 51 | + | ||
| 52 | + # Simulate processing time | ||
| 53 | + time.sleep(0.1) | ||
| 54 | + | ||
| 55 | + # Generate mock search results | ||
| 56 | + results = self.generate_mock_results(query, size, sort_by, filters) | ||
| 57 | + | ||
| 58 | + # Generate mock aggregations | ||
| 59 | + aggregation_results = self.generate_mock_aggregations(aggregations, filters) | ||
| 60 | + | ||
| 61 | + # Build response | ||
| 62 | + response = { | ||
| 63 | + "hits": results, | ||
| 64 | + "total": len(results) + random.randint(10, 100), | ||
| 65 | + "max_score": round(random.uniform(1.5, 3.5), 3), | ||
| 66 | + "took_ms": random.randint(15, 45), | ||
| 67 | + "aggregations": aggregation_results, | ||
| 68 | + "query_info": { | ||
| 69 | + "original_query": query, | ||
| 70 | + "rewritten_query": query, | ||
| 71 | + "detected_language": "zh" if any('\u4e00' <= char <= '\u9fff' for char in query) else "en", | ||
| 72 | + "domain": "default", | ||
| 73 | + "translations": {}, | ||
| 74 | + "has_vector": False | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + # Send response | ||
| 79 | + self.send_response(200) | ||
| 80 | + self.send_header('Content-Type', 'application/json') | ||
| 81 | + self.send_header('Access-Control-Allow-Origin', '*') | ||
| 82 | + self.end_headers() | ||
| 83 | + | ||
| 84 | + response_json = json.dumps(response, ensure_ascii=False, indent=2) | ||
| 85 | + self.wfile.write(response_json.encode('utf-8')) | ||
| 86 | + | ||
| 87 | + print(f"Response sent with {len(results)} results and {len(aggregation_results)} aggregations") | ||
| 88 | + | ||
| 89 | + except Exception as e: | ||
| 90 | + print(f"Error handling request: {e}") | ||
| 91 | + self.send_response(500) | ||
| 92 | + self.send_header('Content-Type', 'application/json') | ||
| 93 | + self.send_header('Access-Control-Allow-Origin', '*') | ||
| 94 | + self.end_headers() | ||
| 95 | + | ||
| 96 | + error_response = { | ||
| 97 | + "error": str(e), | ||
| 98 | + "detail": "Internal server error" | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + self.wfile.write(json.dumps(error_response).encode('utf-8')) | ||
| 102 | + | ||
| 103 | + def generate_mock_results(self, query, size, sort_by, filters): | ||
| 104 | + """Generate mock search results.""" | ||
| 105 | + | ||
| 106 | + # Sample product data | ||
| 107 | + sample_products = [ | ||
| 108 | + { | ||
| 109 | + "skuId": 1001, | ||
| 110 | + "name": "芭比娃娃梦幻套装", | ||
| 111 | + "enSpuName": "Barbie Dream House Playset", | ||
| 112 | + "ruSkuName": "Кукла Барби Мечтательный домик", | ||
| 113 | + "categoryName": "芭比", | ||
| 114 | + "brandName": "美泰", | ||
| 115 | + "supplierName": "义乌玩具厂", | ||
| 116 | + "price": 89.99, | ||
| 117 | + "imageUrl": "https://picsum.photos/seed/barbie1/200/200.jpg", | ||
| 118 | + "create_time": "2024-01-15T10:30:00Z", | ||
| 119 | + "days_since_last_update": 45 | ||
| 120 | + }, | ||
| 121 | + { | ||
| 122 | + "skuId": 1002, | ||
| 123 | + "name": "芭比娃娃时尚系列", | ||
| 124 | + "enSpuName": "Barbie Fashion Doll Collection", | ||
| 125 | + "ruSkuName": "Кукла Барби Модная коллекция", | ||
| 126 | + "categoryName": "芭比", | ||
| 127 | + "brandName": "美泰", | ||
| 128 | + "supplierName": "汕头玩具公司", | ||
| 129 | + "price": 45.50, | ||
| 130 | + "imageUrl": "https://picsum.photos/seed/barbie2/200/200.jpg", | ||
| 131 | + "create_time": "2024-02-20T14:15:00Z", | ||
| 132 | + "days_since_last_update": 30 | ||
| 133 | + }, | ||
| 134 | + { | ||
| 135 | + "skuId": 1003, | ||
| 136 | + "name": "儿童积木套装", | ||
| 137 | + "enSpuName": "Kids Building Blocks Set", | ||
| 138 | + "ruSkuName": "Детский строительный набор", | ||
| 139 | + "categoryName": "积木", | ||
| 140 | + "brandName": "乐高", | ||
| 141 | + "supplierName": "深圳塑胶制品厂", | ||
| 142 | + "price": 158.00, | ||
| 143 | + "imageUrl": "https://picsum.photos/seed/blocks1/200/200.jpg", | ||
| 144 | + "create_time": "2024-01-10T09:20:00Z", | ||
| 145 | + "days_since_last_update": 60 | ||
| 146 | + }, | ||
| 147 | + { | ||
| 148 | + "skuId": 1004, | ||
| 149 | + "name": "消防车玩具模型", | ||
| 150 | + "enSpuName": "Fire Truck Toy Model", | ||
| 151 | + "ruSkuName": "Модель пожарной машины", | ||
| 152 | + "categoryName": "小汽车", | ||
| 153 | + "brandName": "多美卡", | ||
| 154 | + "supplierName": "东莞玩具制造厂", | ||
| 155 | + "price": 78.50, | ||
| 156 | + "imageUrl": "https://picsum.photos/seed/firetruck1/200/200.jpg", | ||
| 157 | + "create_time": "2024-03-05T16:45:00Z", | ||
| 158 | + "days_since_last_update": 15 | ||
| 159 | + }, | ||
| 160 | + { | ||
| 161 | + "skuId": 1005, | ||
| 162 | + "name": "婴儿毛绒玩具", | ||
| 163 | + "enSpuName": "Baby Plush Toy", | ||
| 164 | + "ruSkuName": "Детская плюшевая игрушка", | ||
| 165 | + "categoryName": "婴儿娃娃", | ||
| 166 | + "brandName": "迪士尼", | ||
| 167 | + "supplierName": "上海礼品公司", | ||
| 168 | + "price": 32.00, | ||
| 169 | + "imageUrl": "https://picsum.photos/seed/plush1/200/200.jpg", | ||
| 170 | + "create_time": "2024-02-14T11:30:00Z", | ||
| 171 | + "days_since_last_update": 25 | ||
| 172 | + } | ||
| 173 | + ] | ||
| 174 | + | ||
| 175 | + # Apply filters if any | ||
| 176 | + if filters: | ||
| 177 | + filtered_products = [] | ||
| 178 | + for product in sample_products: | ||
| 179 | + include = True | ||
| 180 | + | ||
| 181 | + # Check category filter | ||
| 182 | + if 'category_name' in filters: | ||
| 183 | + if product['categoryName'] not in filters['category_name']: | ||
| 184 | + include = False | ||
| 185 | + | ||
| 186 | + # Check brand filter | ||
| 187 | + if 'brand_name' in filters: | ||
| 188 | + if product['brandName'] not in filters['brand_name']: | ||
| 189 | + include = False | ||
| 190 | + | ||
| 191 | + # Check price range filter | ||
| 192 | + if 'price_ranges' in filters: | ||
| 193 | + price = product['price'] | ||
| 194 | + in_range = False | ||
| 195 | + for price_range in filters['price_ranges']: | ||
| 196 | + if price_range == '0-50' and price <= 50: | ||
| 197 | + in_range = True | ||
| 198 | + elif price_range == '50-100' and 50 < price <= 100: | ||
| 199 | + in_range = True | ||
| 200 | + elif price_range == '100-200' and 100 < price <= 200: | ||
| 201 | + in_range = True | ||
| 202 | + elif price_range == '200+' and price > 200: | ||
| 203 | + in_range = True | ||
| 204 | + if not in_range: | ||
| 205 | + include = False | ||
| 206 | + | ||
| 207 | + if include: | ||
| 208 | + filtered_products.append(product) | ||
| 209 | + sample_products = filtered_products | ||
| 210 | + | ||
| 211 | + # Apply sorting | ||
| 212 | + if sort_by == 'price_asc': | ||
| 213 | + sample_products.sort(key=lambda x: x.get('price', 0)) | ||
| 214 | + elif sort_by == 'price_desc': | ||
| 215 | + sample_products.sort(key=lambda x: x.get('price', 0), reverse=True) | ||
| 216 | + elif sort_by == 'time_desc': | ||
| 217 | + sample_products.sort(key=lambda x: x.get('create_time', ''), reverse=True) | ||
| 218 | + | ||
| 219 | + # Convert to API response format | ||
| 220 | + results = [] | ||
| 221 | + for i, product in enumerate(sample_products[:size]): | ||
| 222 | + hit = { | ||
| 223 | + "_id": str(product['skuId']), | ||
| 224 | + "_score": round(random.uniform(1.5, 3.5), 3), | ||
| 225 | + "_source": product | ||
| 226 | + } | ||
| 227 | + results.append(hit) | ||
| 228 | + | ||
| 229 | + return results | ||
| 230 | + | ||
| 231 | + def generate_mock_aggregations(self, aggregations, filters): | ||
| 232 | + """Generate mock aggregation results.""" | ||
| 233 | + if not aggregations: | ||
| 234 | + return {} | ||
| 235 | + | ||
| 236 | + result = {} | ||
| 237 | + | ||
| 238 | + for agg_name, agg_spec in aggregations.items(): | ||
| 239 | + agg_type = agg_spec.get('type', 'terms') | ||
| 240 | + | ||
| 241 | + if agg_type == 'terms': | ||
| 242 | + # Generate mock terms aggregation | ||
| 243 | + if agg_name == 'category_name': | ||
| 244 | + buckets = [ | ||
| 245 | + {"key": "芭比", "doc_count": random.randint(15, 35)}, | ||
| 246 | + {"key": "儿童娃娃", "doc_count": random.randint(8, 20)}, | ||
| 247 | + {"key": "积木", "doc_count": random.randint(5, 15)}, | ||
| 248 | + {"key": "小汽车", "doc_count": random.randint(3, 12)}, | ||
| 249 | + {"key": "婴儿娃娃", "doc_count": random.randint(4, 10)}, | ||
| 250 | + {"key": "人物", "doc_count": random.randint(6, 18)} | ||
| 251 | + ] | ||
| 252 | + elif agg_name == 'brand_name': | ||
| 253 | + buckets = [ | ||
| 254 | + {"key": "美泰", "doc_count": random.randint(20, 40)}, | ||
| 255 | + {"key": "乐高", "doc_count": random.randint(10, 25)}, | ||
| 256 | + {"key": "迪士尼", "doc_count": random.randint(8, 20)}, | ||
| 257 | + {"key": "多美卡", "doc_count": random.randint(5, 15)}, | ||
| 258 | + {"key": "孩之宝", "doc_count": random.randint(6, 18)}, | ||
| 259 | + {"key": "万代", "doc_count": random.randint(3, 10)} | ||
| 260 | + ] | ||
| 261 | + elif agg_name == 'material_type': | ||
| 262 | + buckets = [ | ||
| 263 | + {"key": "塑料", "doc_count": random.randint(40, 80)}, | ||
| 264 | + {"key": "布绒", "doc_count": random.randint(8, 20)}, | ||
| 265 | + {"key": "金属", "doc_count": random.randint(5, 15)}, | ||
| 266 | + {"key": "木质", "doc_count": random.randint(3, 12)} | ||
| 267 | + ] | ||
| 268 | + else: | ||
| 269 | + # Generic terms aggregation | ||
| 270 | + buckets = [ | ||
| 271 | + {"key": f"选项{i+1}", "doc_count": random.randint(5, 25)} | ||
| 272 | + for i in range(5) | ||
| 273 | + ] | ||
| 274 | + | ||
| 275 | + result[agg_name] = { | ||
| 276 | + "doc_count_error_upper_bound": 0, | ||
| 277 | + "sum_other_doc_count": random.randint(10, 50), | ||
| 278 | + "buckets": buckets | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + elif agg_type == 'range': | ||
| 282 | + # Generate mock range aggregation (usually for price) | ||
| 283 | + if agg_name == 'price_ranges': | ||
| 284 | + ranges = agg_spec.get('ranges', []) | ||
| 285 | + buckets = [] | ||
| 286 | + for range_spec in ranges: | ||
| 287 | + key = range_spec.get('key', 'unknown') | ||
| 288 | + count = random.randint(5, 30) | ||
| 289 | + bucket_data = {"key": key, "doc_count": count} | ||
| 290 | + | ||
| 291 | + # Add range bounds | ||
| 292 | + if 'to' in range_spec: | ||
| 293 | + bucket_data['to'] = range_spec['to'] | ||
| 294 | + if 'from' in range_spec: | ||
| 295 | + bucket_data['from'] = range_spec['from'] | ||
| 296 | + | ||
| 297 | + buckets.append(bucket_data) | ||
| 298 | + | ||
| 299 | + result[agg_name] = {"buckets": buckets} | ||
| 300 | + | ||
| 301 | + return result | ||
| 302 | + | ||
| 303 | + def log_message(self, format, *args): | ||
| 304 | + """Override to reduce log noise.""" | ||
| 305 | + pass | ||
| 306 | + | ||
| 307 | +def run_server(): | ||
| 308 | + """Run the API server.""" | ||
| 309 | + server_address = ('', 6002) | ||
| 310 | + httpd = HTTPServer(server_address, SearchAPIHandler) | ||
| 311 | + print("🚀 Simple Search API Server started!") | ||
| 312 | + print("📍 API: http://localhost:6002") | ||
| 313 | + print("🔍 Search endpoint: http://localhost:6002/search/") | ||
| 314 | + print("🌐 Frontend should connect to: http://localhost:6002") | ||
| 315 | + print("⏹️ Press Ctrl+C to stop") | ||
| 316 | + | ||
| 317 | + try: | ||
| 318 | + httpd.serve_forever() | ||
| 319 | + except KeyboardInterrupt: | ||
| 320 | + print("\n🛑 Server stopped") | ||
| 321 | + httpd.server_close() | ||
| 322 | + | ||
| 323 | +def run_server(): | ||
| 324 | + """Run the API server - main entry point.""" | ||
| 325 | + server_address = ('', 6002) | ||
| 326 | + httpd = HTTPServer(server_address, SearchAPIHandler) | ||
| 327 | + print("🚀 Simple Search API Server started!") | ||
| 328 | + print("📍 API: http://localhost:6002") | ||
| 329 | + print("🔍 Search endpoint: http://localhost:6002/search/") | ||
| 330 | + print("🌐 Frontend should connect to: http://localhost:6002") | ||
| 331 | + print("⏹️ Press Ctrl+C to stop") | ||
| 332 | + | ||
| 333 | + try: | ||
| 334 | + httpd.serve_forever() | ||
| 335 | + except KeyboardInterrupt: | ||
| 336 | + print("\n🛑 Server stopped") | ||
| 337 | + httpd.server_close() | ||
| 338 | + | ||
| 339 | +if __name__ == '__main__': | ||
| 340 | + run_server() | ||
| 0 | \ No newline at end of file | 341 | \ No newline at end of file |
| @@ -0,0 +1,166 @@ | @@ -0,0 +1,166 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +Test script for aggregation functionality | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import requests | ||
| 7 | +import json | ||
| 8 | + | ||
| 9 | +API_BASE_URL = 'http://120.76.41.98:6002' | ||
| 10 | + | ||
| 11 | +def test_search_with_aggregations(): | ||
| 12 | + """Test search with aggregations""" | ||
| 13 | + | ||
| 14 | + # Test data | ||
| 15 | + test_query = { | ||
| 16 | + "query": "玩具", | ||
| 17 | + "size": 5, | ||
| 18 | + "aggregations": { | ||
| 19 | + "category_stats": { | ||
| 20 | + "terms": { | ||
| 21 | + "field": "categoryName_keyword", | ||
| 22 | + "size": 10 | ||
| 23 | + } | ||
| 24 | + }, | ||
| 25 | + "brand_stats": { | ||
| 26 | + "terms": { | ||
| 27 | + "field": "brandName_keyword", | ||
| 28 | + "size": 10 | ||
| 29 | + } | ||
| 30 | + }, | ||
| 31 | + "price_ranges": { | ||
| 32 | + "range": { | ||
| 33 | + "field": "price", | ||
| 34 | + "ranges": [ | ||
| 35 | + {"key": "0-50", "to": 50}, | ||
| 36 | + {"key": "50-100", "from": 50, "to": 100}, | ||
| 37 | + {"key": "100-200", "from": 100, "to": 200}, | ||
| 38 | + {"key": "200+", "from": 200} | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + print("Testing search with aggregations...") | ||
| 46 | + print(f"Query: {json.dumps(test_query, indent=2, ensure_ascii=False)}") | ||
| 47 | + | ||
| 48 | + try: | ||
| 49 | + response = requests.post(f"{API_BASE_URL}/search/", | ||
| 50 | + json=test_query, | ||
| 51 | + headers={'Content-Type': 'application/json'}) | ||
| 52 | + | ||
| 53 | + print(f"Status Code: {response.status_code}") | ||
| 54 | + | ||
| 55 | + if response.ok: | ||
| 56 | + data = response.json() | ||
| 57 | + print(f"Found {data['total']} results in {data['took_ms']}ms") | ||
| 58 | + print(f"Max Score: {data['max_score']}") | ||
| 59 | + | ||
| 60 | + # Print aggregations | ||
| 61 | + if data.get('aggregations'): | ||
| 62 | + print("\nAggregations:") | ||
| 63 | + for agg_name, agg_result in data['aggregations'].items(): | ||
| 64 | + print(f"\n{agg_name}:") | ||
| 65 | + if 'buckets' in agg_result: | ||
| 66 | + for bucket in agg_result['buckets'][:5]: # Show first 5 buckets | ||
| 67 | + print(f" - {bucket['key']}: {bucket['doc_count']}") | ||
| 68 | + | ||
| 69 | + # Print first few results | ||
| 70 | + print(f"\nFirst 3 results:") | ||
| 71 | + for i, hit in enumerate(data['hits'][:3]): | ||
| 72 | + source = hit['_source'] | ||
| 73 | + print(f"\n{i+1}. {source.get('name', 'N/A')}") | ||
| 74 | + print(f" Category: {source.get('categoryName', 'N/A')}") | ||
| 75 | + print(f" Brand: {source.get('brandName', 'N/A')}") | ||
| 76 | + print(f" Price: {source.get('price', 'N/A')}") | ||
| 77 | + print(f" Score: {hit['_score']:.4f}") | ||
| 78 | + else: | ||
| 79 | + print(f"Error: {response.status_code}") | ||
| 80 | + print(f"Response: {response.text}") | ||
| 81 | + | ||
| 82 | + except Exception as e: | ||
| 83 | + print(f"Request failed: {e}") | ||
| 84 | + | ||
| 85 | +def test_search_with_filters(): | ||
| 86 | + """Test search with filters""" | ||
| 87 | + | ||
| 88 | + test_filters = { | ||
| 89 | + "query": "玩具", | ||
| 90 | + "size": 5, | ||
| 91 | + "filters": { | ||
| 92 | + "categoryName_keyword": ["玩具"], | ||
| 93 | + "price_ranges": ["0-50", "50-100"] | ||
| 94 | + } | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + print("\n\nTesting search with filters...") | ||
| 98 | + print(f"Query: {json.dumps(test_filters, indent=2, ensure_ascii=False)}") | ||
| 99 | + | ||
| 100 | + try: | ||
| 101 | + response = requests.post(f"{API_BASE_URL}/search/", | ||
| 102 | + json=test_filters, | ||
| 103 | + headers={'Content-Type': 'application/json'}) | ||
| 104 | + | ||
| 105 | + print(f"Status Code: {response.status_code}") | ||
| 106 | + | ||
| 107 | + if response.ok: | ||
| 108 | + data = response.json() | ||
| 109 | + print(f"Found {data['total']} results in {data['took_ms']}ms") | ||
| 110 | + | ||
| 111 | + print(f"\nFirst 3 results:") | ||
| 112 | + for i, hit in enumerate(data['hits'][:3]): | ||
| 113 | + source = hit['_source'] | ||
| 114 | + print(f"\n{i+1}. {source.get('name', 'N/A')}") | ||
| 115 | + print(f" Category: {source.get('categoryName', 'N/A')}") | ||
| 116 | + print(f" Brand: {source.get('brandName', 'N/A')}") | ||
| 117 | + print(f" Price: {source.get('price', 'N/A')}") | ||
| 118 | + print(f" Score: {hit['_score']:.4f}") | ||
| 119 | + else: | ||
| 120 | + print(f"Error: {response.status_code}") | ||
| 121 | + print(f"Response: {response.text}") | ||
| 122 | + | ||
| 123 | + except Exception as e: | ||
| 124 | + print(f"Request failed: {e}") | ||
| 125 | + | ||
| 126 | +def test_search_with_sorting(): | ||
| 127 | + """Test search with sorting""" | ||
| 128 | + | ||
| 129 | + test_sort = { | ||
| 130 | + "query": "玩具", | ||
| 131 | + "size": 5, | ||
| 132 | + "sort_by": "price", | ||
| 133 | + "sort_order": "asc" | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + print("\n\nTesting search with sorting (price ascending)...") | ||
| 137 | + print(f"Query: {json.dumps(test_sort, indent=2, ensure_ascii=False)}") | ||
| 138 | + | ||
| 139 | + try: | ||
| 140 | + response = requests.post(f"{API_BASE_URL}/search/", | ||
| 141 | + json=test_sort, | ||
| 142 | + headers={'Content-Type': 'application/json'}) | ||
| 143 | + | ||
| 144 | + print(f"Status Code: {response.status_code}") | ||
| 145 | + | ||
| 146 | + if response.ok: | ||
| 147 | + data = response.json() | ||
| 148 | + print(f"Found {data['total']} results in {data['took_ms']}ms") | ||
| 149 | + | ||
| 150 | + print(f"\nFirst 3 results (sorted by price):") | ||
| 151 | + for i, hit in enumerate(data['hits'][:3]): | ||
| 152 | + source = hit['_source'] | ||
| 153 | + print(f"\n{i+1}. {source.get('name', 'N/A')}") | ||
| 154 | + print(f" Price: {source.get('price', 'N/A')}") | ||
| 155 | + print(f" Score: {hit['_score']:.4f}") | ||
| 156 | + else: | ||
| 157 | + print(f"Error: {response.status_code}") | ||
| 158 | + print(f"Response: {response.text}") | ||
| 159 | + | ||
| 160 | + except Exception as e: | ||
| 161 | + print(f"Request failed: {e}") | ||
| 162 | + | ||
| 163 | +if __name__ == "__main__": | ||
| 164 | + test_search_with_aggregations() | ||
| 165 | + test_search_with_filters() | ||
| 166 | + test_search_with_sorting() | ||
| 0 | \ No newline at end of file | 167 | \ No newline at end of file |
| @@ -0,0 +1,236 @@ | @@ -0,0 +1,236 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +Simple test script to verify aggregation functionality without external dependencies. | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import sys | ||
| 7 | +import os | ||
| 8 | + | ||
| 9 | +# Add the project root to the Python path | ||
| 10 | +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | ||
| 11 | + | ||
| 12 | +def test_es_query_builder_aggregations(): | ||
| 13 | + """Test the ES query builder aggregation methods.""" | ||
| 14 | + print("Testing ES Query Builder Aggregation Methods...") | ||
| 15 | + | ||
| 16 | + # Import the query builder | ||
| 17 | + try: | ||
| 18 | + from search.es_query_builder import ESQueryBuilder | ||
| 19 | + print("✓ ESQueryBuilder imported successfully") | ||
| 20 | + except ImportError as e: | ||
| 21 | + print(f"✗ Failed to import ESQueryBuilder: {e}") | ||
| 22 | + return False | ||
| 23 | + | ||
| 24 | + # Create a query builder instance | ||
| 25 | + builder = ESQueryBuilder( | ||
| 26 | + index_name="test_index", | ||
| 27 | + match_fields=["name", "description"] | ||
| 28 | + ) | ||
| 29 | + | ||
| 30 | + # Test basic aggregation | ||
| 31 | + es_query = {"query": {"match_all": {}}} | ||
| 32 | + | ||
| 33 | + # Test add_dynamic_aggregations | ||
| 34 | + aggregations = { | ||
| 35 | + "category_name": { | ||
| 36 | + "type": "terms", | ||
| 37 | + "field": "categoryName_keyword", | ||
| 38 | + "size": 10 | ||
| 39 | + }, | ||
| 40 | + "price_ranges": { | ||
| 41 | + "type": "range", | ||
| 42 | + "field": "price", | ||
| 43 | + "ranges": [ | ||
| 44 | + {"key": "0-50", "to": 50}, | ||
| 45 | + {"key": "50-100", "from": 50, "to": 100} | ||
| 46 | + ] | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + result_query = builder.add_dynamic_aggregations(es_query, aggregations) | ||
| 51 | + | ||
| 52 | + if "aggs" in result_query: | ||
| 53 | + print("✓ Aggregations added to query") | ||
| 54 | + | ||
| 55 | + # Check category aggregation | ||
| 56 | + if "category_name" in result_query["aggs"]: | ||
| 57 | + category_agg = result_query["aggs"]["category_name"] | ||
| 58 | + if "terms" in category_agg and category_agg["terms"]["field"] == "categoryName_keyword": | ||
| 59 | + print("✓ Category aggregation correctly configured") | ||
| 60 | + else: | ||
| 61 | + print("✗ Category aggregation incorrectly configured") | ||
| 62 | + return False | ||
| 63 | + | ||
| 64 | + # Check price range aggregation | ||
| 65 | + if "price_ranges" in result_query["aggs"]: | ||
| 66 | + price_agg = result_query["aggs"]["price_ranges"] | ||
| 67 | + if "range" in price_agg and price_agg["range"]["field"] == "price": | ||
| 68 | + print("✓ Price range aggregation correctly configured") | ||
| 69 | + else: | ||
| 70 | + print("✗ Price range aggregation incorrectly configured") | ||
| 71 | + return False | ||
| 72 | + else: | ||
| 73 | + print("✗ No aggregations added to query") | ||
| 74 | + return False | ||
| 75 | + | ||
| 76 | + # Test sorting | ||
| 77 | + result_query_asc = builder.add_sorting({}, "price_asc") | ||
| 78 | + if "sort" in result_query_asc: | ||
| 79 | + print("✓ Price ascending sort added") | ||
| 80 | + else: | ||
| 81 | + print("✗ Price ascending sort not added") | ||
| 82 | + return False | ||
| 83 | + | ||
| 84 | + result_query_desc = builder.add_sorting({}, "price_desc") | ||
| 85 | + if "sort" in result_query_desc: | ||
| 86 | + print("✓ Price descending sort added") | ||
| 87 | + else: | ||
| 88 | + print("✗ Price descending sort not added") | ||
| 89 | + return False | ||
| 90 | + | ||
| 91 | + result_query_time = builder.add_sorting({}, "time_desc") | ||
| 92 | + if "sort" in result_query_time: | ||
| 93 | + print("✓ Time descending sort added") | ||
| 94 | + else: | ||
| 95 | + print("✗ Time descending sort not added") | ||
| 96 | + return False | ||
| 97 | + | ||
| 98 | + return True | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def test_searcher_integration(): | ||
| 102 | + """Test searcher integration with new parameters.""" | ||
| 103 | + print("\nTesting Searcher Integration...") | ||
| 104 | + | ||
| 105 | + try: | ||
| 106 | + from search.searcher import Searcher | ||
| 107 | + print("✓ Searcher imported successfully") | ||
| 108 | + except ImportError as e: | ||
| 109 | + print(f"✗ Failed to import Searcher: {e}") | ||
| 110 | + return False | ||
| 111 | + | ||
| 112 | + # We can't easily test the full searcher without ES, but we can check the method signature | ||
| 113 | + import inspect | ||
| 114 | + search_method = getattr(Searcher, 'search', None) | ||
| 115 | + | ||
| 116 | + if search_method: | ||
| 117 | + sig = inspect.signature(search_method) | ||
| 118 | + params = list(sig.parameters.keys()) | ||
| 119 | + | ||
| 120 | + expected_params = ['query', 'size', 'from_', 'filters', 'min_score', 'aggregations', 'sort_by', 'context'] | ||
| 121 | + for param in expected_params: | ||
| 122 | + if param in params: | ||
| 123 | + print(f"✓ Parameter '{param}' found in search method") | ||
| 124 | + else: | ||
| 125 | + print(f"✗ Parameter '{param}' missing from search method") | ||
| 126 | + return False | ||
| 127 | + else: | ||
| 128 | + print("✗ Search method not found in Searcher class") | ||
| 129 | + return False | ||
| 130 | + | ||
| 131 | + return True | ||
| 132 | + | ||
| 133 | + | ||
| 134 | +def test_api_route_integration(): | ||
| 135 | + """Test API route integration.""" | ||
| 136 | + print("\nTesting API Route Integration...") | ||
| 137 | + | ||
| 138 | + try: | ||
| 139 | + from api.routes.search import router | ||
| 140 | + print("✓ Search router imported successfully") | ||
| 141 | + except ImportError as e: | ||
| 142 | + print(f"✗ Failed to import search router: {e}") | ||
| 143 | + return False | ||
| 144 | + | ||
| 145 | + # Check if the route exists | ||
| 146 | + routes = [route.path for route in router.routes] | ||
| 147 | + if "/" in routes: | ||
| 148 | + print("✓ Main search route found") | ||
| 149 | + else: | ||
| 150 | + print("✗ Main search route not found") | ||
| 151 | + return False | ||
| 152 | + | ||
| 153 | + return True | ||
| 154 | + | ||
| 155 | + | ||
| 156 | +def test_configuration(): | ||
| 157 | + """Test configuration parsing.""" | ||
| 158 | + print("\nTesting Configuration...") | ||
| 159 | + | ||
| 160 | + try: | ||
| 161 | + from config import CustomerConfig | ||
| 162 | + print("✓ CustomerConfig imported successfully") | ||
| 163 | + except ImportError as e: | ||
| 164 | + print(f"✗ Failed to import CustomerConfig: {e}") | ||
| 165 | + return False | ||
| 166 | + | ||
| 167 | + # Try to load the customer1 config | ||
| 168 | + try: | ||
| 169 | + config = CustomerConfig.load_from_file("config/schema/customer1_config.yaml") | ||
| 170 | + print("✓ Customer1 configuration loaded successfully") | ||
| 171 | + | ||
| 172 | + # Check if price field is in the configuration | ||
| 173 | + field_names = [field.name for field in config.fields] | ||
| 174 | + if "price" in field_names: | ||
| 175 | + print("✓ Price field found in configuration") | ||
| 176 | + else: | ||
| 177 | + print("✗ Price field not found in configuration") | ||
| 178 | + return False | ||
| 179 | + | ||
| 180 | + # Check keyword fields for aggregations | ||
| 181 | + if "categoryName_keyword" in field_names: | ||
| 182 | + print("✓ Category keyword field found") | ||
| 183 | + else: | ||
| 184 | + print("✗ Category keyword field not found") | ||
| 185 | + return False | ||
| 186 | + | ||
| 187 | + if "brandName_keyword" in field_names: | ||
| 188 | + print("✓ Brand keyword field found") | ||
| 189 | + else: | ||
| 190 | + print("✗ Brand keyword field not found") | ||
| 191 | + return False | ||
| 192 | + | ||
| 193 | + except Exception as e: | ||
| 194 | + print(f"✗ Failed to load configuration: {e}") | ||
| 195 | + return False | ||
| 196 | + | ||
| 197 | + return True | ||
| 198 | + | ||
| 199 | + | ||
| 200 | +def main(): | ||
| 201 | + """Run all tests.""" | ||
| 202 | + print("=== Search Engine Aggregation Functionality Tests ===\n") | ||
| 203 | + | ||
| 204 | + tests = [ | ||
| 205 | + test_es_query_builder_aggregations, | ||
| 206 | + test_searcher_integration, | ||
| 207 | + test_api_route_integration, | ||
| 208 | + test_configuration | ||
| 209 | + ] | ||
| 210 | + | ||
| 211 | + passed = 0 | ||
| 212 | + total = len(tests) | ||
| 213 | + | ||
| 214 | + for test in tests: | ||
| 215 | + try: | ||
| 216 | + if test(): | ||
| 217 | + passed += 1 | ||
| 218 | + print(f"✓ {test.__name__} PASSED") | ||
| 219 | + else: | ||
| 220 | + print(f"✗ {test.__name__} FAILED") | ||
| 221 | + except Exception as e: | ||
| 222 | + print(f"✗ {test.__name__} ERROR: {e}") | ||
| 223 | + | ||
| 224 | + print(f"\n=== Test Results: {passed}/{total} tests passed ===") | ||
| 225 | + | ||
| 226 | + if passed == total: | ||
| 227 | + print("🎉 All tests passed! Aggregation functionality is ready.") | ||
| 228 | + return True | ||
| 229 | + else: | ||
| 230 | + print("❌ Some tests failed. Please check the implementation.") | ||
| 231 | + return False | ||
| 232 | + | ||
| 233 | + | ||
| 234 | +if __name__ == "__main__": | ||
| 235 | + success = main() | ||
| 236 | + sys.exit(0 if success else 1) | ||
| 0 | \ No newline at end of file | 237 | \ No newline at end of file |
| @@ -0,0 +1,211 @@ | @@ -0,0 +1,211 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +Complete test script simulating frontend search interaction | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import requests | ||
| 7 | +import json | ||
| 8 | + | ||
| 9 | +API_BASE_URL = 'http://120.76.41.98:6002' | ||
| 10 | + | ||
| 11 | +def test_complete_search_workflow(): | ||
| 12 | + """Test complete search workflow similar to frontend""" | ||
| 13 | + | ||
| 14 | + print("=" * 60) | ||
| 15 | + print("完整搜索流程测试") | ||
| 16 | + print("=" * 60) | ||
| 17 | + | ||
| 18 | + # Step 1: Initial search with aggregations | ||
| 19 | + print("\n1️⃣ 初始搜索(带聚合功能)") | ||
| 20 | + print("-" * 30) | ||
| 21 | + | ||
| 22 | + search_request = { | ||
| 23 | + "query": "芭比娃娃", | ||
| 24 | + "size": 10, | ||
| 25 | + "aggregations": { | ||
| 26 | + "category_stats": { | ||
| 27 | + "terms": { | ||
| 28 | + "field": "categoryName_keyword", | ||
| 29 | + "size": 10 | ||
| 30 | + } | ||
| 31 | + }, | ||
| 32 | + "brand_stats": { | ||
| 33 | + "terms": { | ||
| 34 | + "field": "brandName_keyword", | ||
| 35 | + "size": 10 | ||
| 36 | + } | ||
| 37 | + }, | ||
| 38 | + "price_ranges": { | ||
| 39 | + "range": { | ||
| 40 | + "field": "price", | ||
| 41 | + "ranges": [ | ||
| 42 | + {"key": "0-50", "to": 50}, | ||
| 43 | + {"key": "50-100", "from": 50, "to": 100}, | ||
| 44 | + {"key": "100-200", "from": 100, "to": 200}, | ||
| 45 | + {"key": "200+", "from": 200} | ||
| 46 | + ] | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + try: | ||
| 53 | + response = requests.post(f"{API_BASE_URL}/search/", json=search_request) | ||
| 54 | + | ||
| 55 | + if response.ok: | ||
| 56 | + data = response.json() | ||
| 57 | + print(f"✅ 找到 {data['total']} 个结果,耗时 {data['took_ms']}ms") | ||
| 58 | + | ||
| 59 | + # Show aggregations results | ||
| 60 | + if data.get('aggregations'): | ||
| 61 | + print("\n📊 聚合结果:") | ||
| 62 | + | ||
| 63 | + # Category aggregations | ||
| 64 | + if 'category_stats' in data['aggregations']: | ||
| 65 | + print(" 🏷️ 分类统计:") | ||
| 66 | + for bucket in data['aggregations']['category_stats']['buckets'][:3]: | ||
| 67 | + print(f" - {bucket['key']}: {bucket['doc_count']} 个商品") | ||
| 68 | + | ||
| 69 | + # Brand aggregations | ||
| 70 | + if 'brand_stats' in data['aggregations']: | ||
| 71 | + print(" 🏢 品牌统计:") | ||
| 72 | + for bucket in data['aggregations']['brand_stats']['buckets'][:3]: | ||
| 73 | + print(f" - {bucket['key']}: {bucket['doc_count']} 个商品") | ||
| 74 | + | ||
| 75 | + # Price ranges | ||
| 76 | + if 'price_ranges' in data['aggregations']: | ||
| 77 | + print(" 💰 价格分布:") | ||
| 78 | + for bucket in data['aggregations']['price_ranges']['buckets']: | ||
| 79 | + print(f" - {bucket['key']}: {bucket['doc_count']} 个商品") | ||
| 80 | + | ||
| 81 | + # Show sample results | ||
| 82 | + print(f"\n🔍 前3个搜索结果:") | ||
| 83 | + for i, hit in enumerate(data['hits'][:3]): | ||
| 84 | + source = hit['_source'] | ||
| 85 | + price = source.get('price', 'N/A') | ||
| 86 | + category = source.get('categoryName', 'N/A') | ||
| 87 | + brand = source.get('brandName', 'N/A') | ||
| 88 | + print(f" {i+1}. {source.get('name', 'N/A')}") | ||
| 89 | + print(f" 💰 价格: {price}") | ||
| 90 | + print(f" 📁 分类: {category}") | ||
| 91 | + print(f" 🏷️ 品牌: {brand}") | ||
| 92 | + print(f" ⭐ 评分: {hit['_score']:.3f}") | ||
| 93 | + print() | ||
| 94 | + | ||
| 95 | + else: | ||
| 96 | + print(f"❌ 搜索失败: {response.status_code}") | ||
| 97 | + print(f"错误信息: {response.text}") | ||
| 98 | + | ||
| 99 | + except Exception as e: | ||
| 100 | + print(f"❌ 请求异常: {e}") | ||
| 101 | + | ||
| 102 | + # Step 2: Search with filters | ||
| 103 | + print("\n2️⃣ 带过滤条件的搜索") | ||
| 104 | + print("-" * 30) | ||
| 105 | + | ||
| 106 | + filtered_search = { | ||
| 107 | + "query": "芭比娃娃", | ||
| 108 | + "size": 5, | ||
| 109 | + "filters": { | ||
| 110 | + "brandName_keyword": ["美泰"], | ||
| 111 | + "price_ranges": ["50-100", "100-200"] | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + try: | ||
| 116 | + response = requests.post(f"{API_BASE_URL}/search/", json=filtered_search) | ||
| 117 | + | ||
| 118 | + if response.ok: | ||
| 119 | + data = response.json() | ||
| 120 | + print(f"✅ 过滤后找到 {data['total']} 个结果,耗时 {data['took_ms']}ms") | ||
| 121 | + print(" 🎯 过滤条件: 品牌=美泰, 价格=¥50-200") | ||
| 122 | + | ||
| 123 | + print(f"\n💫 前3个过滤结果:") | ||
| 124 | + for i, hit in enumerate(data['hits'][:3]): | ||
| 125 | + source = hit['_source'] | ||
| 126 | + price = source.get('price', 'N/A') | ||
| 127 | + category = source.get('categoryName', 'N/A') | ||
| 128 | + brand = source.get('brandName', 'N/A') | ||
| 129 | + print(f" {i+1}. {source.get('name', 'N/A')}") | ||
| 130 | + print(f" 💰 ¥{price} | 📁 {category} | 🏷️ {brand}") | ||
| 131 | + print(f" ⭐ 评分: {hit['_score']:.3f}") | ||
| 132 | + | ||
| 133 | + else: | ||
| 134 | + print(f"❌ 过滤搜索失败: {response.status_code}") | ||
| 135 | + | ||
| 136 | + except Exception as e: | ||
| 137 | + print(f"❌ 请求异常: {e}") | ||
| 138 | + | ||
| 139 | + # Step 3: Search with sorting | ||
| 140 | + print("\n3️⃣ 排序搜索") | ||
| 141 | + print("-" * 30) | ||
| 142 | + | ||
| 143 | + # Test price ascending | ||
| 144 | + price_asc_search = { | ||
| 145 | + "query": "芭比娃娃", | ||
| 146 | + "size": 3, | ||
| 147 | + "sort_by": "price", | ||
| 148 | + "sort_order": "asc" | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + try: | ||
| 152 | + response = requests.post(f"{API_BASE_URL}/search/", json=price_asc_search) | ||
| 153 | + | ||
| 154 | + if response.ok: | ||
| 155 | + data = response.json() | ||
| 156 | + print(f"✅ 价格升序排序,找到 {data['total']} 个结果") | ||
| 157 | + print(" 📈 排序方式: 价格从低到高") | ||
| 158 | + | ||
| 159 | + print(f"\n💵 价格排序结果:") | ||
| 160 | + for i, hit in enumerate(data['hits']): | ||
| 161 | + source = hit['_source'] | ||
| 162 | + price = source.get('price', 'N/A') | ||
| 163 | + name = source.get('name', 'N/A') | ||
| 164 | + print(f" {i+1}. ¥{price} - {name}") | ||
| 165 | + | ||
| 166 | + else: | ||
| 167 | + print(f"❌ 排序搜索失败: {response.status_code}") | ||
| 168 | + | ||
| 169 | + except Exception as e: | ||
| 170 | + print(f"❌ 请求异常: {e}") | ||
| 171 | + | ||
| 172 | + # Step 4: Test time sorting | ||
| 173 | + print("\n4️⃣ 时间排序测试") | ||
| 174 | + print("-" * 30) | ||
| 175 | + | ||
| 176 | + time_sort_search = { | ||
| 177 | + "query": "芭比娃娃", | ||
| 178 | + "size": 3, | ||
| 179 | + "sort_by": "create_time", | ||
| 180 | + "sort_order": "desc" | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + try: | ||
| 184 | + response = requests.post(f"{API_BASE_URL}/search/", json=time_sort_search) | ||
| 185 | + | ||
| 186 | + if response.ok: | ||
| 187 | + data = response.json() | ||
| 188 | + print(f"✅ 时间降序排序,找到 {data['total']} 个结果") | ||
| 189 | + print(" 📅 排序方式: 上架时间从新到旧") | ||
| 190 | + | ||
| 191 | + print(f"\n🕐 时间排序结果:") | ||
| 192 | + for i, hit in enumerate(data['hits']): | ||
| 193 | + source = hit['_source'] | ||
| 194 | + create_time = source.get('create_time', 'N/A') | ||
| 195 | + name = source.get('name', 'N/A') | ||
| 196 | + print(f" {i+1}. {create_time} - {name}") | ||
| 197 | + | ||
| 198 | + else: | ||
| 199 | + print(f"❌ 时间排序失败: {response.status_code}") | ||
| 200 | + | ||
| 201 | + except Exception as e: | ||
| 202 | + print(f"❌ 请求异常: {e}") | ||
| 203 | + | ||
| 204 | + print("\n" + "=" * 60) | ||
| 205 | + print("🎉 搜索功能测试完成!") | ||
| 206 | + print("✨ 前端访问地址: http://localhost:8080") | ||
| 207 | + print("🔧 后端API地址: http://120.76.41.98:6002") | ||
| 208 | + print("=" * 60) | ||
| 209 | + | ||
| 210 | +if __name__ == "__main__": | ||
| 211 | + test_complete_search_workflow() | ||
| 0 | \ No newline at end of file | 212 | \ No newline at end of file |
| @@ -0,0 +1,45 @@ | @@ -0,0 +1,45 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +Minimal test to isolate sort issue | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import requests | ||
| 7 | +import json | ||
| 8 | + | ||
| 9 | +def test_minimal_sort(): | ||
| 10 | + """Test minimal sort case""" | ||
| 11 | + | ||
| 12 | + base_url = "http://120.76.41.98:6002" | ||
| 13 | + | ||
| 14 | + # Test 1: No sort parameters | ||
| 15 | + print("Test 1: No sort parameters") | ||
| 16 | + response = requests.post(f"{base_url}/search/", json={"query": "test", "size": 1}) | ||
| 17 | + print(f"Status: {response.status_code}") | ||
| 18 | + print(f"Response: {response.text[:200]}...") | ||
| 19 | + | ||
| 20 | + # Test 2: Empty sort_by | ||
| 21 | + print("\nTest 2: Empty sort_by") | ||
| 22 | + response = requests.post(f"{base_url}/search/", json={"query": "test", "size": 1, "sort_by": ""}) | ||
| 23 | + print(f"Status: {response.status_code}") | ||
| 24 | + print(f"Response: {response.text[:200]}...") | ||
| 25 | + | ||
| 26 | + # Test 3: sort_by only (no sort_order) | ||
| 27 | + print("\nTest 3: sort_by only") | ||
| 28 | + response = requests.post(f"{base_url}/search/", json={"query": "test", "size": 1, "sort_by": "create_time"}) | ||
| 29 | + print(f"Status: {response.status_code}") | ||
| 30 | + print(f"Response: {response.text[:200]}...") | ||
| 31 | + | ||
| 32 | + # Test 4: sort_order only (no sort_by) | ||
| 33 | + print("\nTest 4: sort_order only") | ||
| 34 | + response = requests.post(f"{base_url}/search/", json={"query": "test", "size": 1, "sort_order": "desc"}) | ||
| 35 | + print(f"Status: {response.status_code}") | ||
| 36 | + print(f"Response: {response.text[:200]}...") | ||
| 37 | + | ||
| 38 | + # Test 5: Both parameters with None values | ||
| 39 | + print("\nTest 5: Both parameters with null values") | ||
| 40 | + response = requests.post(f"{base_url}/search/", json={"query": "test", "size": 1, "sort_by": None, "sort_order": None}) | ||
| 41 | + print(f"Status: {response.status_code}") | ||
| 42 | + print(f"Response: {response.text[:200]}...") | ||
| 43 | + | ||
| 44 | +if __name__ == "__main__": | ||
| 45 | + test_minimal_sort() | ||
| 0 | \ No newline at end of file | 46 | \ No newline at end of file |
| @@ -0,0 +1,256 @@ | @@ -0,0 +1,256 @@ | ||
| 1 | +""" | ||
| 2 | +Tests for aggregation API functionality. | ||
| 3 | +""" | ||
| 4 | + | ||
| 5 | +import pytest | ||
| 6 | +from fastapi.testclient import TestClient | ||
| 7 | +from api.app import app | ||
| 8 | + | ||
| 9 | +client = TestClient(app) | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +@pytest.mark.integration | ||
| 13 | +@pytest.mark.api | ||
| 14 | +def test_search_with_aggregations(): | ||
| 15 | + """Test search with dynamic aggregations.""" | ||
| 16 | + request_data = { | ||
| 17 | + "query": "芭比娃娃", | ||
| 18 | + "size": 10, | ||
| 19 | + "aggregations": { | ||
| 20 | + "category_name": { | ||
| 21 | + "type": "terms", | ||
| 22 | + "field": "categoryName_keyword", | ||
| 23 | + "size": 10 | ||
| 24 | + }, | ||
| 25 | + "brand_name": { | ||
| 26 | + "type": "terms", | ||
| 27 | + "field": "brandName_keyword", | ||
| 28 | + "size": 10 | ||
| 29 | + }, | ||
| 30 | + "price_ranges": { | ||
| 31 | + "type": "range", | ||
| 32 | + "field": "price", | ||
| 33 | + "ranges": [ | ||
| 34 | + {"key": "0-50", "to": 50}, | ||
| 35 | + {"key": "50-100", "from": 50, "to": 100}, | ||
| 36 | + {"key": "100-200", "from": 100, "to": 200}, | ||
| 37 | + {"key": "200+", "from": 200} | ||
| 38 | + ] | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + response = client.post("/search/", json=request_data) | ||
| 44 | + | ||
| 45 | + assert response.status_code == 200 | ||
| 46 | + data = response.json() | ||
| 47 | + | ||
| 48 | + # Check basic search response structure | ||
| 49 | + assert "hits" in data | ||
| 50 | + assert "total" in data | ||
| 51 | + assert "aggregations" in data | ||
| 52 | + assert "query_info" in data | ||
| 53 | + | ||
| 54 | + # Check aggregations structure | ||
| 55 | + aggregations = data["aggregations"] | ||
| 56 | + | ||
| 57 | + # Should have category aggregations | ||
| 58 | + if "category_name" in aggregations: | ||
| 59 | + assert "buckets" in aggregations["category_name"] | ||
| 60 | + assert isinstance(aggregations["category_name"]["buckets"], list) | ||
| 61 | + | ||
| 62 | + # Should have brand aggregations | ||
| 63 | + if "brand_name" in aggregations: | ||
| 64 | + assert "buckets" in aggregations["brand_name"] | ||
| 65 | + assert isinstance(aggregations["brand_name"]["buckets"], list) | ||
| 66 | + | ||
| 67 | + # Should have price range aggregations | ||
| 68 | + if "price_ranges" in aggregations: | ||
| 69 | + assert "buckets" in aggregations["price_ranges"] | ||
| 70 | + assert isinstance(aggregations["price_ranges"]["buckets"], list) | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +@pytest.mark.integration | ||
| 74 | +@pytest.mark.api | ||
| 75 | +def test_search_with_sorting(): | ||
| 76 | + """Test search with different sorting options.""" | ||
| 77 | + | ||
| 78 | + # Test price ascending | ||
| 79 | + request_data = { | ||
| 80 | + "query": "玩具", | ||
| 81 | + "size": 5, | ||
| 82 | + "sort_by": "price_asc" | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + response = client.post("/search/", json=request_data) | ||
| 86 | + assert response.status_code == 200 | ||
| 87 | + data = response.json() | ||
| 88 | + | ||
| 89 | + if data["hits"] and len(data["hits"]) > 1: | ||
| 90 | + # Check if results are sorted by price (ascending) | ||
| 91 | + prices = [] | ||
| 92 | + for hit in data["hits"]: | ||
| 93 | + if "_source" in hit and "price" in hit["_source"]: | ||
| 94 | + prices.append(hit["_source"]["price"]) | ||
| 95 | + | ||
| 96 | + if len(prices) > 1: | ||
| 97 | + assert prices == sorted(prices), "Results should be sorted by price ascending" | ||
| 98 | + | ||
| 99 | + # Test price descending | ||
| 100 | + request_data["sort_by"] = "price_desc" | ||
| 101 | + response = client.post("/search/", json=request_data) | ||
| 102 | + assert response.status_code == 200 | ||
| 103 | + data = response.json() | ||
| 104 | + | ||
| 105 | + if data["hits"] and len(data["hits"]) > 1: | ||
| 106 | + prices = [] | ||
| 107 | + for hit in data["hits"]: | ||
| 108 | + if "_source" in hit and "price" in hit["_source"]: | ||
| 109 | + prices.append(hit["_source"]["price"]) | ||
| 110 | + | ||
| 111 | + if len(prices) > 1: | ||
| 112 | + assert prices == sorted(prices, reverse=True), "Results should be sorted by price descending" | ||
| 113 | + | ||
| 114 | + # Test time descending | ||
| 115 | + request_data["sort_by"] = "time_desc" | ||
| 116 | + response = client.post("/search/", json=request_data) | ||
| 117 | + assert response.status_code == 200 | ||
| 118 | + data = response.json() | ||
| 119 | + | ||
| 120 | + if data["hits"] and len(data["hits"]) > 1: | ||
| 121 | + times = [] | ||
| 122 | + for hit in data["hits"]: | ||
| 123 | + if "_source" in hit and "create_time" in hit["_source"]: | ||
| 124 | + times.append(hit["_source"]["create_time"]) | ||
| 125 | + | ||
| 126 | + if len(times) > 1: | ||
| 127 | + # Newer items should come first | ||
| 128 | + assert times == sorted(times, reverse=True), "Results should be sorted by time descending" | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +@pytest.mark.integration | ||
| 132 | +@pytest.mark.api | ||
| 133 | +def test_search_with_filters_and_aggregations(): | ||
| 134 | + """Test search with filters and aggregations together.""" | ||
| 135 | + request_data = { | ||
| 136 | + "query": "玩具", | ||
| 137 | + "size": 10, | ||
| 138 | + "filters": { | ||
| 139 | + "category_name": ["芭比"] | ||
| 140 | + }, | ||
| 141 | + "aggregations": { | ||
| 142 | + "brand_name": { | ||
| 143 | + "type": "terms", | ||
| 144 | + "field": "brandName_keyword", | ||
| 145 | + "size": 10 | ||
| 146 | + } | ||
| 147 | + } | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + response = client.post("/search/", json=request_data) | ||
| 151 | + assert response.status_code == 200 | ||
| 152 | + data = response.json() | ||
| 153 | + | ||
| 154 | + # Check that results are filtered | ||
| 155 | + assert "hits" in data | ||
| 156 | + for hit in data["hits"]: | ||
| 157 | + if "_source" in hit and "categoryName" in hit["_source"]: | ||
| 158 | + assert "芭比" in hit["_source"]["categoryName"] | ||
| 159 | + | ||
| 160 | + # Check that aggregations are still present | ||
| 161 | + assert "aggregations" in data | ||
| 162 | + | ||
| 163 | + | ||
| 164 | +@pytest.mark.integration | ||
| 165 | +@pytest.mark.api | ||
| 166 | +def test_search_without_aggregations(): | ||
| 167 | + """Test search without aggregations (default behavior).""" | ||
| 168 | + request_data = { | ||
| 169 | + "query": "玩具", | ||
| 170 | + "size": 10 | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + response = client.post("/search/", json=request_data) | ||
| 174 | + assert response.status_code == 200 | ||
| 175 | + data = response.json() | ||
| 176 | + | ||
| 177 | + # Should still have basic response structure | ||
| 178 | + assert "hits" in data | ||
| 179 | + assert "total" in data | ||
| 180 | + assert "query_info" in data | ||
| 181 | + | ||
| 182 | + # Aggregations might be empty or not present without explicit request | ||
| 183 | + assert "aggregations" in data | ||
| 184 | + | ||
| 185 | + | ||
| 186 | +@pytest.mark.integration | ||
| 187 | +@pytest.mark.api | ||
| 188 | +def test_aggregation_edge_cases(): | ||
| 189 | + """Test aggregation edge cases.""" | ||
| 190 | + | ||
| 191 | + # Test with empty query | ||
| 192 | + request_data = { | ||
| 193 | + "query": "", | ||
| 194 | + "size": 10, | ||
| 195 | + "aggregations": { | ||
| 196 | + "category_name": { | ||
| 197 | + "type": "terms", | ||
| 198 | + "field": "categoryName_keyword", | ||
| 199 | + "size": 10 | ||
| 200 | + } | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + response = client.post("/search/", json=request_data) | ||
| 205 | + # Should handle empty query gracefully | ||
| 206 | + assert response.status_code in [200, 422] | ||
| 207 | + | ||
| 208 | + # Test with invalid aggregation type | ||
| 209 | + request_data = { | ||
| 210 | + "query": "玩具", | ||
| 211 | + "size": 10, | ||
| 212 | + "aggregations": { | ||
| 213 | + "invalid_agg": { | ||
| 214 | + "type": "invalid_type", | ||
| 215 | + "field": "categoryName_keyword", | ||
| 216 | + "size": 10 | ||
| 217 | + } | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + response = client.post("/search/", json=request_data) | ||
| 222 | + # Should handle invalid aggregation type gracefully | ||
| 223 | + assert response.status_code in [200, 422] | ||
| 224 | + | ||
| 225 | + | ||
| 226 | +@pytest.mark.unit | ||
| 227 | +def test_aggregation_spec_validation(): | ||
| 228 | + """Test aggregation specification validation.""" | ||
| 229 | + from api.models import AggregationSpec | ||
| 230 | + | ||
| 231 | + # Test valid aggregation spec | ||
| 232 | + agg_spec = AggregationSpec( | ||
| 233 | + field="categoryName_keyword", | ||
| 234 | + type="terms", | ||
| 235 | + size=10 | ||
| 236 | + ) | ||
| 237 | + assert agg_spec.field == "categoryName_keyword" | ||
| 238 | + assert agg_spec.type == "terms" | ||
| 239 | + assert agg_spec.size == 10 | ||
| 240 | + | ||
| 241 | + # Test range aggregation spec | ||
| 242 | + range_agg = AggregationSpec( | ||
| 243 | + field="price", | ||
| 244 | + type="range", | ||
| 245 | + ranges=[ | ||
| 246 | + {"key": "0-50", "to": 50}, | ||
| 247 | + {"key": "50-100", "from": 50, "to": 100} | ||
| 248 | + ] | ||
| 249 | + ) | ||
| 250 | + assert range_agg.field == "price" | ||
| 251 | + assert range_agg.type == "range" | ||
| 252 | + assert len(range_agg.ranges) == 2 | ||
| 253 | + | ||
| 254 | + | ||
| 255 | +if __name__ == "__main__": | ||
| 256 | + pytest.main([__file__]) | ||
| 0 | \ No newline at end of file | 257 | \ No newline at end of file |