Commit a866b68809b1de63342da87e95e62c717a039ccd
1 parent
3cd09b3b
翻译接口
Showing
3 changed files
with
138 additions
and
202 deletions
Show diff stats
| @@ -0,0 +1,117 @@ | @@ -0,0 +1,117 @@ | ||
| 1 | +# SKU image_src 字段为空问题诊断报告 | ||
| 2 | + | ||
| 3 | +## 问题描述 | ||
| 4 | + | ||
| 5 | +返回结果的每条结果中,多款式字段 `skus` 下面每个 SKU 的 `image_src` 为空。 | ||
| 6 | + | ||
| 7 | +## 问题分析 | ||
| 8 | + | ||
| 9 | +### 1. ES 数据检查 | ||
| 10 | + | ||
| 11 | +通过查询 ES 数据,发现: | ||
| 12 | +- ES 中确实有 `skus` 数据(不是空数组) | ||
| 13 | +- 但是 `skus` 数组中的每个 SKU 对象**都没有 `image_src` 字段** | ||
| 14 | + | ||
| 15 | +示例 ES 文档: | ||
| 16 | +```json | ||
| 17 | +{ | ||
| 18 | + "spu_id": "68238", | ||
| 19 | + "skus": [ | ||
| 20 | + { | ||
| 21 | + "sku_id": "3568395", | ||
| 22 | + "price": 329.61, | ||
| 23 | + "compare_at_price": 485.65, | ||
| 24 | + "sku_code": "3468269", | ||
| 25 | + "stock": 57, | ||
| 26 | + "weight": 0.26, | ||
| 27 | + "weight_unit": "kg", | ||
| 28 | + "option1_value": "", | ||
| 29 | + "option2_value": "", | ||
| 30 | + "option3_value": "" | ||
| 31 | + // 注意:这里没有 image_src 字段 | ||
| 32 | + } | ||
| 33 | + ] | ||
| 34 | +} | ||
| 35 | +``` | ||
| 36 | + | ||
| 37 | +### 2. 代码逻辑检查 | ||
| 38 | + | ||
| 39 | +在 `indexer/document_transformer.py` 的 `_transform_sku_row` 方法中(第558-560行),原有逻辑为: | ||
| 40 | + | ||
| 41 | +```python | ||
| 42 | +# Image src | ||
| 43 | +if pd.notna(sku_row.get('image_src')): | ||
| 44 | + sku_data['image_src'] = str(sku_row['image_src']) | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +**问题根源**: | ||
| 48 | +- 只有当 MySQL 中的 `image_src` 字段**非空**时,才会将其添加到 `sku_data` 字典中 | ||
| 49 | +- 如果 MySQL 中的 `image_src` 是 `NULL` 或空字符串,这个字段就**不会出现在返回的字典中** | ||
| 50 | +- 导致 ES 文档中缺少 `image_src` 字段 | ||
| 51 | +- API 返回时,`sku_entry.get('image_src')` 返回 `None`,前端看到的就是空值 | ||
| 52 | + | ||
| 53 | +### 3. MySQL 数据情况 | ||
| 54 | + | ||
| 55 | +根据代码逻辑推断: | ||
| 56 | +- MySQL 的 `shoplazza_product_sku` 表中,`image_src` 字段可能为 `NULL` 或空字符串 | ||
| 57 | +- 这导致索引时该字段没有被写入 ES | ||
| 58 | + | ||
| 59 | +## 解决方案 | ||
| 60 | + | ||
| 61 | +### 修复方案 | ||
| 62 | + | ||
| 63 | +修改 `indexer/document_transformer.py` 中的 `_transform_sku_row` 方法,**始终包含 `image_src` 字段**,即使值为空也设置为 `None`: | ||
| 64 | + | ||
| 65 | +```python | ||
| 66 | +# Image src - always include this field, even if empty | ||
| 67 | +# This ensures the field is present in ES documents and API responses | ||
| 68 | +image_src = sku_row.get('image_src') | ||
| 69 | +if pd.notna(image_src) and str(image_src).strip(): | ||
| 70 | + sku_data['image_src'] = str(image_src).strip() | ||
| 71 | +else: | ||
| 72 | + # Set to None (will be serialized as null in JSON) instead of omitting the field | ||
| 73 | + sku_data['image_src'] = None | ||
| 74 | +``` | ||
| 75 | + | ||
| 76 | +### 修复效果 | ||
| 77 | + | ||
| 78 | +修复后: | ||
| 79 | +1. **即使 MySQL 中 `image_src` 为 NULL 或空字符串**,ES 文档中也会包含该字段(值为 `null`) | ||
| 80 | +2. API 返回时,前端可以明确知道该字段存在但值为空 | ||
| 81 | +3. 符合 API 模型定义:`image_src: Optional[str] = Field(None, ...)` | ||
| 82 | + | ||
| 83 | +## 问题分类 | ||
| 84 | + | ||
| 85 | +**问题类型**:**本项目填充的问题** | ||
| 86 | + | ||
| 87 | +- ✅ **不是 MySQL 原始数据的问题**:MySQL 中 `image_src` 字段可能确实为 NULL,但这是正常的业务数据 | ||
| 88 | +- ✅ **不是 ES 数据的问题**:ES mapping 中 `image_src` 字段定义正确 | ||
| 89 | +- ❌ **是本项目填充的问题**:代码逻辑导致当 MySQL 中 `image_src` 为空时,该字段没有被写入 ES 文档 | ||
| 90 | + | ||
| 91 | +## 后续操作 | ||
| 92 | + | ||
| 93 | +1. **重新索引数据**:修复代码后,需要重新索引数据才能生效 | ||
| 94 | + ```bash | ||
| 95 | + # 重新索引指定租户的数据 | ||
| 96 | + ./scripts/ingest.sh <tenant_id> true | ||
| 97 | + ``` | ||
| 98 | + | ||
| 99 | +2. **验证修复**:重新索引后,查询 ES 验证 `image_src` 字段是否已包含: | ||
| 100 | + ```bash | ||
| 101 | + curl -u 'essa:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' \ | ||
| 102 | + -H 'Content-Type: application/json' \ | ||
| 103 | + -d '{ | ||
| 104 | + "size": 1, | ||
| 105 | + "query": {"nested": {"path": "skus", "query": {"exists": {"field": "skus"}}}}, | ||
| 106 | + "_source": ["spu_id", "skus"] | ||
| 107 | + }' | ||
| 108 | + ``` | ||
| 109 | + | ||
| 110 | +3. **可选优化**:如果业务需要,可以考虑当 SKU 的 `image_src` 为空时,使用 SPU 的主图(`image_url`)作为默认值 | ||
| 111 | + | ||
| 112 | +## 相关文件 | ||
| 113 | + | ||
| 114 | +- `indexer/document_transformer.py` - 已修复 | ||
| 115 | +- `api/models.py` - `SkuResult.image_src: Optional[str]` - 模型定义正确 | ||
| 116 | +- `api/result_formatter.py` - `image_src=sku_entry.get('image_src')` - 读取逻辑正确 | ||
| 117 | +- `mappings/search_products.json` - `skus.image_src` mapping 定义正确 |
docs/翻译功能测试说明.md deleted
| @@ -1,197 +0,0 @@ | @@ -1,197 +0,0 @@ | ||
| 1 | -# 翻译功能测试说明 | ||
| 2 | - | ||
| 3 | -## 功能概述 | ||
| 4 | - | ||
| 5 | -本次更新实现了以下功能: | ||
| 6 | - | ||
| 7 | -1. **翻译提示词配置**:支持中英文提示词,用于提高翻译质量 | ||
| 8 | -2. **DeepL Context参数**:提示词作为DeepL API的`context`参数传递(不参与翻译,仅提供上下文) | ||
| 9 | -3. **同步/异步翻译**: | ||
| 10 | - - 索引场景:同步翻译,等待结果返回 | ||
| 11 | - - 查询场景:异步翻译,立即返回缓存结果 | ||
| 12 | -4. **缓存机制**:翻译结果自动缓存,避免重复翻译 | ||
| 13 | - | ||
| 14 | -## 配置说明 | ||
| 15 | - | ||
| 16 | -### 配置文件位置 | ||
| 17 | - | ||
| 18 | -`config/config.yaml` | ||
| 19 | - | ||
| 20 | -### 翻译提示词配置 | ||
| 21 | - | ||
| 22 | -```yaml | ||
| 23 | -translation_prompts: | ||
| 24 | - # 商品标题翻译提示词 | ||
| 25 | - product_title.zh: "请将原文翻译成中文商品SKU名称,要求:确保精确、完整地传达原文信息的基础上,语言简洁清晰、地道、专业。" | ||
| 26 | - product_title.en: "Translate the original text into an English product SKU name. Requirements: Ensure accurate and complete transmission of the original information, with concise, clear, authentic, and professional language." | ||
| 27 | - # query翻译提示词 | ||
| 28 | - query_zh: "电商领域" | ||
| 29 | - query_en: "e-commerce domain" | ||
| 30 | - # 默认翻译用词 | ||
| 31 | - default_zh: "电商领域" | ||
| 32 | - default_en: "e-commerce domain" | ||
| 33 | -``` | ||
| 34 | - | ||
| 35 | -### 提示词使用规则 | ||
| 36 | - | ||
| 37 | -1. **商品标题翻译**: | ||
| 38 | - - 中文→英文:使用 `product_title.en` | ||
| 39 | - - 英文→中文:使用 `product_title.zh` | ||
| 40 | - | ||
| 41 | -2. **其他字段翻译**(brief, description, vendor): | ||
| 42 | - - 根据目标语言选择 `default_zh` 或 `default_en` | ||
| 43 | - | ||
| 44 | -3. **查询翻译**: | ||
| 45 | - - 根据目标语言选择 `query_zh` 或 `query_en` | ||
| 46 | - | ||
| 47 | -## 测试方法 | ||
| 48 | - | ||
| 49 | -### 1. 测试配置加载 | ||
| 50 | - | ||
| 51 | -```python | ||
| 52 | -from config import ConfigLoader | ||
| 53 | - | ||
| 54 | -config_loader = ConfigLoader() | ||
| 55 | -config = config_loader.load_config() | ||
| 56 | - | ||
| 57 | -# 检查翻译提示词配置 | ||
| 58 | -print(config.query_config.translation_prompts) | ||
| 59 | -``` | ||
| 60 | - | ||
| 61 | -### 2. 测试同步翻译(索引场景) | ||
| 62 | - | ||
| 63 | -```python | ||
| 64 | -from query.translator import Translator | ||
| 65 | -from config import ConfigLoader | ||
| 66 | - | ||
| 67 | -config = ConfigLoader().load_config() | ||
| 68 | -translator = Translator( | ||
| 69 | - api_key=config.query_config.translation_api_key, | ||
| 70 | - use_cache=True | ||
| 71 | -) | ||
| 72 | - | ||
| 73 | -# 测试商品标题翻译 | ||
| 74 | -text = "蓝牙耳机" | ||
| 75 | -prompt = config.query_config.translation_prompts.get('product_title.en') | ||
| 76 | -result = translator.translate( | ||
| 77 | - text, | ||
| 78 | - target_lang='en', | ||
| 79 | - source_lang='zh', | ||
| 80 | - prompt=prompt | ||
| 81 | -) | ||
| 82 | -print(f"翻译结果: {result}") | ||
| 83 | -``` | ||
| 84 | - | ||
| 85 | -### 3. 测试异步翻译(查询场景) | ||
| 86 | - | ||
| 87 | -```python | ||
| 88 | -# 异步模式(立即返回,后台翻译) | ||
| 89 | -results = translator.translate_multi( | ||
| 90 | - "手机", | ||
| 91 | - target_langs=['en'], | ||
| 92 | - source_lang='zh', | ||
| 93 | - async_mode=True, | ||
| 94 | - prompt=config.query_config.translation_prompts.get('query_zh') | ||
| 95 | -) | ||
| 96 | -print(f"异步结果: {results}") # 可能包含None(后台翻译中) | ||
| 97 | - | ||
| 98 | -# 同步模式(等待完成) | ||
| 99 | -results_sync = translator.translate_multi( | ||
| 100 | - "手机", | ||
| 101 | - target_langs=['en'], | ||
| 102 | - source_lang='zh', | ||
| 103 | - async_mode=False, | ||
| 104 | - prompt=config.query_config.translation_prompts.get('query_zh') | ||
| 105 | -) | ||
| 106 | -print(f"同步结果: {results_sync}") | ||
| 107 | -``` | ||
| 108 | - | ||
| 109 | -### 4. 测试文档转换器集成 | ||
| 110 | - | ||
| 111 | -```python | ||
| 112 | -from indexer.document_transformer import SPUDocumentTransformer | ||
| 113 | -import pandas as pd | ||
| 114 | - | ||
| 115 | -# 创建模拟数据 | ||
| 116 | -spu_row = pd.Series({ | ||
| 117 | - 'id': 123, | ||
| 118 | - 'tenant_id': '1', | ||
| 119 | - 'title': '蓝牙耳机', | ||
| 120 | - 'brief': '高品质无线蓝牙耳机', | ||
| 121 | - 'description': '这是一款高品质的无线蓝牙耳机。', | ||
| 122 | - 'vendor': '品牌A', | ||
| 123 | - # ... 其他字段 | ||
| 124 | -}) | ||
| 125 | - | ||
| 126 | -# 初始化转换器(带翻译器) | ||
| 127 | -transformer = SPUDocumentTransformer( | ||
| 128 | - category_id_to_name={}, | ||
| 129 | - searchable_option_dimensions=['option1', 'option2', 'option3'], | ||
| 130 | - tenant_config={'primary_language': 'zh', 'translate_to_en': True}, | ||
| 131 | - translator=translator, | ||
| 132 | - translation_prompts=config.query_config.translation_prompts | ||
| 133 | -) | ||
| 134 | - | ||
| 135 | -# 转换文档 | ||
| 136 | -doc = transformer.transform_spu_to_doc( | ||
| 137 | - tenant_id='1', | ||
| 138 | - spu_row=spu_row, | ||
| 139 | - skus=pd.DataFrame(), | ||
| 140 | - options=pd.DataFrame() | ||
| 141 | -) | ||
| 142 | - | ||
| 143 | -print(f"title.zh: {doc.get('title.zh')}") | ||
| 144 | -print(f"title.en: {doc.get('title.en')}") # 应该包含翻译结果 | ||
| 145 | -``` | ||
| 146 | - | ||
| 147 | -### 5. 测试缓存功能 | ||
| 148 | - | ||
| 149 | -```python | ||
| 150 | -# 第一次翻译(调用API) | ||
| 151 | -result1 = translator.translate("测试文本", "en", "zh", prompt="电商领域") | ||
| 152 | - | ||
| 153 | -# 第二次翻译(使用缓存) | ||
| 154 | -result2 = translator.translate("测试文本", "en", "zh", prompt="电商领域") | ||
| 155 | - | ||
| 156 | -assert result1 == result2 # 应该相同 | ||
| 157 | -``` | ||
| 158 | - | ||
| 159 | -## DeepL API Context参数说明 | ||
| 160 | - | ||
| 161 | -根据 [DeepL API文档](https://developers.deepl.com/api-reference/translate/request-translation): | ||
| 162 | - | ||
| 163 | -- `context` 参数:Additional context that can influence a translation but is not translated itself | ||
| 164 | -- Context中的字符不计入计费 | ||
| 165 | -- Context用于提供翻译上下文,帮助提高翻译质量 | ||
| 166 | - | ||
| 167 | -我们的实现: | ||
| 168 | -- 将提示词作为 `context` 参数传递给DeepL API | ||
| 169 | -- Context不参与翻译,仅提供上下文信息 | ||
| 170 | -- 不同场景使用不同的提示词(商品标题、查询、默认) | ||
| 171 | - | ||
| 172 | -## 运行完整测试 | ||
| 173 | - | ||
| 174 | -```bash | ||
| 175 | -# 激活环境 | ||
| 176 | -source /home/tw/miniconda3/etc/profile.d/conda.sh | ||
| 177 | -conda activate searchengine | ||
| 178 | - | ||
| 179 | -# 运行测试脚本 | ||
| 180 | -python scripts/test_translation.py | ||
| 181 | -``` | ||
| 182 | - | ||
| 183 | -## 验证要点 | ||
| 184 | - | ||
| 185 | -1. **配置加载**:确认所有提示词配置正确加载 | ||
| 186 | -2. **同步翻译**:索引时翻译结果正确填充到文档 | ||
| 187 | -3. **异步翻译**:查询时缓存命中立即返回,未命中后台翻译 | ||
| 188 | -4. **提示词使用**:不同场景使用正确的提示词 | ||
| 189 | -5. **缓存机制**:相同文本和提示词的翻译结果被缓存 | ||
| 190 | - | ||
| 191 | -## 注意事项 | ||
| 192 | - | ||
| 193 | -1. 需要配置 `DEEPL_AUTH_KEY` 环境变量或 `translation_api_key` | ||
| 194 | -2. 如果没有API key,翻译器会返回原文(mock模式) | ||
| 195 | -3. 缓存文件存储在 `.cache/translations.json` | ||
| 196 | -4. Context参数中的字符不计入DeepL计费 | ||
| 197 | - |
query/translator.py
| @@ -141,7 +141,9 @@ class Translator: | @@ -141,7 +141,9 @@ class Translator: | ||
| 141 | ) | 141 | ) |
| 142 | # Test connection | 142 | # Test connection |
| 143 | self.redis_client.ping() | 143 | self.redis_client.ping() |
| 144 | - self.expire_time = timedelta(days=REDIS_CONFIG.get('translation_cache_expire_days', 360)) | 144 | + expire_days = REDIS_CONFIG.get('translation_cache_expire_days', 360) |
| 145 | + self.expire_time = timedelta(days=expire_days) | ||
| 146 | + self.expire_seconds = int(self.expire_time.total_seconds()) # Redis 需要秒数 | ||
| 145 | self.cache_prefix = REDIS_CONFIG.get('translation_cache_prefix', 'trans') | 147 | self.cache_prefix = REDIS_CONFIG.get('translation_cache_prefix', 'trans') |
| 146 | logger.info("Redis cache initialized for translations") | 148 | logger.info("Redis cache initialized for translations") |
| 147 | except Exception as e: | 149 | except Exception as e: |
| @@ -622,7 +624,13 @@ class Translator: | @@ -622,7 +624,13 @@ class Translator: | ||
| 622 | context: Optional[str] = None, | 624 | context: Optional[str] = None, |
| 623 | prompt: Optional[str] = None | 625 | prompt: Optional[str] = None |
| 624 | ) -> Optional[str]: | 626 | ) -> Optional[str]: |
| 625 | - """Get translation from Redis cache with sliding expiration.""" | 627 | + """ |
| 628 | + Get translation from Redis cache with sliding expiration. | ||
| 629 | + | ||
| 630 | + 滑动过期机制:每次访问缓存时,重置过期时间为配置的过期时间(默认720天)。 | ||
| 631 | + 这样缓存会在最后一次访问后的720天才过期,而不是写入后的720天。 | ||
| 632 | + 这确保了常用的翻译缓存不会被过早删除。 | ||
| 633 | + """ | ||
| 626 | if not self.redis_client: | 634 | if not self.redis_client: |
| 627 | return None | 635 | return None |
| 628 | 636 | ||
| @@ -634,10 +642,18 @@ class Translator: | @@ -634,10 +642,18 @@ class Translator: | ||
| 634 | value = self.redis_client.get(cache_key) | 642 | value = self.redis_client.get(cache_key) |
| 635 | if value: | 643 | if value: |
| 636 | # Sliding expiration: reset expiration time on access | 644 | # Sliding expiration: reset expiration time on access |
| 637 | - self.redis_client.expire(cache_key, self.expire_time) | 645 | + # 每次读取缓存时,重置过期时间为配置的过期时间(最后一次访问后的N天才过期) |
| 646 | + try: | ||
| 647 | + self.redis_client.expire(cache_key, self.expire_seconds) | ||
| 648 | + except Exception as expire_error: | ||
| 649 | + # 即使 expire 失败,也返回缓存值(不影响功能) | ||
| 650 | + logger.warning( | ||
| 651 | + f"[Translator] Failed to update cache expiration for key {cache_key}: {expire_error}" | ||
| 652 | + ) | ||
| 653 | + | ||
| 638 | logger.debug( | 654 | logger.debug( |
| 639 | f"[Translator] Redis cache hit | Original text: '{text}' | Target language: {target_lang} | " | 655 | f"[Translator] Redis cache hit | Original text: '{text}' | Target language: {target_lang} | " |
| 640 | - f"Cache key: {cache_key} | Translation result: '{value}'" | 656 | + f"Cache key: {cache_key} | Translation result: '{value}' | TTL reset to {self.expire_seconds}s" |
| 641 | ) | 657 | ) |
| 642 | return value | 658 | return value |
| 643 | logger.debug( | 659 | logger.debug( |
| @@ -664,7 +680,7 @@ class Translator: | @@ -664,7 +680,7 @@ class Translator: | ||
| 664 | 680 | ||
| 665 | try: | 681 | try: |
| 666 | cache_key = f"{self.cache_prefix}:{target_lang.upper()}:{text}" | 682 | cache_key = f"{self.cache_prefix}:{target_lang.upper()}:{text}" |
| 667 | - self.redis_client.setex(cache_key, self.expire_time, translation) | 683 | + self.redis_client.setex(cache_key, self.expire_seconds, translation) |
| 668 | logger.debug( | 684 | logger.debug( |
| 669 | f"[Translator] Redis cache write | Original text: '{text}' | Target language: {target_lang} | " | 685 | f"[Translator] Redis cache write | Original text: '{text}' | Target language: {target_lang} | " |
| 670 | f"Cache key: {cache_key} | Translation result: '{translation}'" | 686 | f"Cache key: {cache_key} | Translation result: '{translation}'" |