Commit a866b68809b1de63342da87e95e62c717a039ccd

Authored by tangwang
1 parent 3cd09b3b

翻译接口

docs/temporary/sku_image_src问题诊断报告.md 0 → 100644
... ... @@ -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   -# 翻译功能测试说明
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 141 )
142 142 # Test connection
143 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 147 self.cache_prefix = REDIS_CONFIG.get('translation_cache_prefix', 'trans')
146 148 logger.info("Redis cache initialized for translations")
147 149 except Exception as e:
... ... @@ -622,7 +624,13 @@ class Translator:
622 624 context: Optional[str] = None,
623 625 prompt: Optional[str] = None
624 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 634 if not self.redis_client:
627 635 return None
628 636  
... ... @@ -634,10 +642,18 @@ class Translator:
634 642 value = self.redis_client.get(cache_key)
635 643 if value:
636 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 654 logger.debug(
639 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 658 return value
643 659 logger.debug(
... ... @@ -664,7 +680,7 @@ class Translator:
664 680  
665 681 try:
666 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 684 logger.debug(
669 685 f"[Translator] Redis cache write | Original text: '{text}' | Target language: {target_lang} | "
670 686 f"Cache key: {cache_key} | Translation result: '{translation}'"
... ...