Commit a866b68809b1de63342da87e95e62c717a039ccd

Authored by tangwang
1 parent 3cd09b3b

翻译接口

docs/temporary/sku_image_src问题诊断报告.md 0 → 100644
@@ -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}'"