Commit 5aaf0c7d5291f2e7e4f9702de54a722024cb87c3

Authored by tangwang
1 parent 36516857

feat(indexer): 完善 enriched_taxonomy_attributes 接口输出及缓存设计

- `/indexer/enrich-content` 路由`enriched_taxonomy_attributes` 与
  `enriched_attributes` 一并返回
- 新增请求参数 `analysis_kinds`(可选,默认 `["content",
  "taxonomy"]`),允许调用方按需选择内容分析类型,为后续扩展和成本控制预留空间
- 重构缓存策略:将 `content` 与 `taxonomy` 两类分析的缓存完全隔离,缓存
  key 包含 prompt 模板、表头、输出字段定义(即 schema
指纹),确保提示词或解析规则变更时自动失效
- 缓存 key 仅依赖真正参与 LLM
  输入的字段(`title`、`brief`、`description`),`image_url`、`tenant_id`、`spu_id`
不再污染缓存键,提高缓存命中率
- 更新 API
  文档(`docs/搜索API对接指南-05-索引接口(Indexer).md`),说明新增参数与返回字段

技术细节:
- 路由层调整:在 `api/routes/indexer.py` 的 enrich-content 端点中,将
  `product_enrich.enrich_products_batch` 返回的
`enriched_taxonomy_attributes` 字段显式加入 HTTP 响应体
- `analysis_kinds` 参数透传至底层
  `enrich_products_batch`,支持按需跳过某一类分析(如仅需 taxonomy
时减少 LLM 调用)
- 缓存指纹计算位于 `product_enrich.py` 的 `_get_cache_key` 函数,对每种
  `AnalysisSchema` 独立生成;版本号通过 `schema.version` 或 prompt
内容哈希隐式包含
- 测试覆盖:新增 `analysis_kinds` 组合场景及缓存隔离测试
api/routes/indexer.py
... ... @@ -7,7 +7,7 @@
7 7 import asyncio
8 8 import re
9 9 from fastapi import APIRouter, HTTPException
10   -from typing import Any, Dict, List, Optional
  10 +from typing import Any, Dict, List, Literal, Optional
11 11 from pydantic import BaseModel, Field
12 12 import logging
13 13 from sqlalchemy import text
... ... @@ -88,11 +88,20 @@ class EnrichContentItem(BaseModel):
88 88  
89 89 class EnrichContentRequest(BaseModel):
90 90 """
91   - 内容理解字段生成请求:根据商品标题批量生成 qanchors、enriched_attributes、tags
  91 + 内容理解字段生成请求:根据商品标题批量生成 qanchors、enriched_attributes、tags、taxonomy attributes
92 92 供外部 indexer 在自行组织 doc 时调用,与翻译、向量化等微服务并列。
93 93 """
94 94 tenant_id: str = Field(..., description="租户 ID,用于请求路由与结果归属,不参与缓存键")
95 95 items: List[EnrichContentItem] = Field(..., description="待分析的 SPU 列表(spu_id + title,可附带 brief/description/image_url)")
  96 + analysis_kinds: List[Literal["content", "taxonomy"]] = Field(
  97 + default_factory=lambda: ["content", "taxonomy"],
  98 + description=(
  99 + "要执行的分析族。"
  100 + "`content` 返回 qanchors/enriched_tags/enriched_attributes;"
  101 + "`taxonomy` 返回 enriched_taxonomy_attributes。"
  102 + "默认两者都执行。"
  103 + ),
  104 + )
96 105  
97 106  
98 107 @router.post("/reindex")
... ... @@ -440,20 +449,29 @@ async def build_docs_from_db(request: BuildDocsFromDbRequest):
440 449 raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
441 450  
442 451  
443   -def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]]) -> List[Dict[str, Any]]:
  452 +def _run_enrich_content(
  453 + tenant_id: str,
  454 + items: List[Dict[str, str]],
  455 + analysis_kinds: Optional[List[str]] = None,
  456 +) -> List[Dict[str, Any]]:
444 457 """
445 458 同步执行内容理解,返回与 ES mapping 对齐的字段结构。
446 459 语言策略由 product_enrich 内部统一决定,路由层不参与。
447 460 """
448 461 from indexer.product_enrich import build_index_content_fields
449 462  
450   - results = build_index_content_fields(items=items, tenant_id=tenant_id)
  463 + results = build_index_content_fields(
  464 + items=items,
  465 + tenant_id=tenant_id,
  466 + analysis_kinds=analysis_kinds,
  467 + )
451 468 return [
452 469 {
453 470 "spu_id": item["id"],
454 471 "qanchors": item["qanchors"],
455 472 "enriched_attributes": item["enriched_attributes"],
456 473 "enriched_tags": item["enriched_tags"],
  474 + "enriched_taxonomy_attributes": item["enriched_taxonomy_attributes"],
457 475 **({"error": item["error"]} if item.get("error") else {}),
458 476 }
459 477 for item in results
... ... @@ -463,15 +481,15 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]]) -> List[Dic
463 481 @router.post("/enrich-content")
464 482 async def enrich_content(request: EnrichContentRequest):
465 483 """
466   - 内容理解字段生成接口:根据商品标题批量生成 qanchors、enriched_attributes、tags
  484 + 内容理解字段生成接口:根据商品标题批量生成 qanchors、enriched_attributes、tags、taxonomy attributes
467 485  
468 486 使用场景:
469 487 - 外部 indexer 采用「微服务组合」方式自己组织 doc 时,可调用本接口获取 LLM 生成的
470 488 锚文本与语义属性,再与翻译、向量化结果合并写入 ES。
471 489 - 与 /indexer/build-docs 解耦,避免 build-docs 因 LLM 耗时过长而阻塞;调用方可
472   - 先拿不含 qanchors/enriched_tags 的 doc,再异步或离线补齐本接口结果后更新 ES。
  490 + 先拿不含 qanchors/enriched_tags/taxonomy attributes 的 doc,再异步或离线补齐本接口结果后更新 ES。
473 491  
474   - 实现逻辑与 indexer.product_enrich.analyze_products 一致,支持多语言与 Redis 缓存。
  492 + 实现逻辑与 indexer.product_enrich.build_index_content_fields 一致,支持多语言与 Redis 缓存。
475 493 """
476 494 try:
477 495 if not request.items:
... ... @@ -497,11 +515,13 @@ async def enrich_content(request: EnrichContentRequest):
497 515 None,
498 516 lambda: _run_enrich_content(
499 517 tenant_id=request.tenant_id,
500   - items=items_payload
  518 + items=items_payload,
  519 + analysis_kinds=request.analysis_kinds,
501 520 ),
502 521 )
503 522 return {
504 523 "tenant_id": request.tenant_id,
  524 + "analysis_kinds": request.analysis_kinds,
505 525 "results": result,
506 526 "total": len(result),
507 527 }
... ...
docs/搜索API对接指南-05-索引接口(Indexer).md
... ... @@ -648,13 +648,14 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
648 648 ### 5.8 内容理解字段生成接口
649 649  
650 650 - **端点**: `POST /indexer/enrich-content`
651   -- **描述**: 根据商品内容信息批量生成 **qanchors**(锚文本)、**enriched_attributes**(语义属性)、**enriched_tags**(细分标签),供外部 indexer 在「微服务组合」方式下自行拼装 doc 时使用。请求以 `items[]` 传入商品内容字段(必填/可选见下表)。接口只暴露商品内容输入,语言选择、分析维度与最终字段结构统一由 `indexer.product_enrich` 内部决定;当前返回结果与 `search_products` mapping 保持一致。单次请求在线程池中执行,避免阻塞其他接口。
  651 +- **描述**: 根据商品内容信息批量生成 **qanchors**(锚文本)、**enriched_attributes**(通用语义属性)、**enriched_tags**(细分标签)、**enriched_taxonomy_attributes**(taxonomy 结构化属性),供外部 indexer 在「微服务组合」方式下自行拼装 doc 时使用。请求以 `items[]` 传入商品内容字段(必填/可选见下表)。接口只暴露商品内容输入,语言选择、分析维度与最终字段结构统一由 `indexer.product_enrich` 内部决定;当前返回结果与 `search_products` mapping 保持一致。单次请求在线程池中执行,避免阻塞其他接口。
652 652  
653 653 #### 请求参数
654 654  
655 655 ```json
656 656 {
657 657 "tenant_id": "170",
  658 + "analysis_kinds": ["content", "taxonomy"],
658 659 "items": [
659 660 {
660 661 "spu_id": "223167",
... ... @@ -675,6 +676,7 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
675 676 | 参数 | 类型 | 必填 | 默认值 | 说明 |
676 677 |------|------|------|--------|------|
677 678 | `tenant_id` | string | Y | - | 租户 ID。目前仅用于记录日志,不产生实际作用|
  679 +| `analysis_kinds` | array[string] | N | `["content", "taxonomy"]` | 选择要执行的分析族。`content` 生成 `qanchors`/`enriched_tags`/`enriched_attributes`,`taxonomy` 生成 `enriched_taxonomy_attributes` |
678 680 | `items` | array | Y | - | 待分析列表;**单次最多 50 条** |
679 681  
680 682 `items[]` 字段说明:
... ... @@ -683,15 +685,18 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
683 685 |------|------|------|------|
684 686 | `spu_id` | string | Y | SPU ID,用于回填结果;目前仅用于记录日志,不产生实际作用|
685 687 | `title` | string | Y | 商品标题 |
686   -| `image_url` | string | N | 商品主图 URL;当前会参与内容缓存键,后续可用于图像/多模态内容理解 |
687   -| `brief` | string | N | 商品简介/短描述;当前会参与内容缓存键 |
688   -| `description` | string | N | 商品详情/长描述;当前会参与内容缓存键 |
  688 +| `image_url` | string | N | 商品主图 URL;当前仅透传,暂未参与 prompt 与缓存键,后续可用于图像/多模态内容理解 |
  689 +| `brief` | string | N | 商品简介/短描述;当前会参与 prompt 与缓存键 |
  690 +| `description` | string | N | 商品详情/长描述;当前会参与 prompt 与缓存键 |
689 691  
690 692 缓存说明:
691 693  
692   -- 内容缓存键仅由 `target_lang + items[]` 中会影响内容理解结果的输入文本构成,目前包括:`title`、`brief`、`description`、`image_url` 的规范化内容 hash。
  694 +- 内容缓存按 **分析族拆分**,即 `content` 与 `taxonomy` 使用不同的缓存命名空间,互不污染、可独立演进。
  695 +- 缓存键由 `analysis_kind + target_lang + prompt/schema 版本指纹 + prompt 输入文本 hash` 构成。
  696 +- 当前真正参与 prompt 输入的字段是:`title`、`brief`、`description`;这些字段任一变化,都会落到新的缓存 key。
  697 +- `prompt/schema 版本指纹` 会综合 system prompt、shared instruction、localized table headers、result fields、user instruction template 等信息生成;因此只要提示词或输出契约变化,旧缓存会自然失效。
693 698 - `tenant_id`、`spu_id` 只用于请求归属与结果回填,不参与缓存键。
694   -- 因此,输入内容不变时可跨请求直接命中缓存;任一输入字段变化时,会自然落到新的缓存 key。
  699 +- 因此,输入内容与 prompt 契约都不变时可跨请求直接命中缓存;任一一侧变化,都会自然落到新的缓存 key。
695 700  
696 701 语言说明:
697 702  
... ... @@ -709,6 +714,7 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
709 714 ```json
710 715 {
711 716 "tenant_id": "170",
  717 + "analysis_kinds": ["content", "taxonomy"],
712 718 "total": 2,
713 719 "results": [
714 720 {
... ... @@ -725,6 +731,11 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
725 731 { "name": "enriched_tags", "value": { "zh": "纯棉" } },
726 732 { "name": "usage_scene", "value": { "zh": "日常" } },
727 733 { "name": "enriched_tags", "value": { "en": "cotton" } }
  734 + ],
  735 + "enriched_taxonomy_attributes": [
  736 + { "name": "Product Type", "value": { "zh": ["T恤"], "en": ["t-shirt"] } },
  737 + { "name": "Target Gender", "value": { "zh": ["男"], "en": ["men"] } },
  738 + { "name": "Season", "value": { "zh": ["夏季"], "en": ["summer"] } }
728 739 ]
729 740 },
730 741 {
... ... @@ -735,7 +746,8 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
735 746 "enriched_tags": {
736 747 "en": ["dolls", "toys"]
737 748 },
738   - "enriched_attributes": []
  749 + "enriched_attributes": [],
  750 + "enriched_taxonomy_attributes": []
739 751 }
740 752 ]
741 753 }
... ... @@ -743,10 +755,12 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
743 755  
744 756 | 字段 | 类型 | 说明 |
745 757 |------|------|------|
746   -| `results` | array | 与请求 `items` 一一对应,每项含 `spu_id`、`qanchors`、`enriched_attributes`、`enriched_tags` |
  758 +| `analysis_kinds` | array | 实际执行的分析族列表 |
  759 +| `results` | array | 与请求 `items` 一一对应,每项含 `spu_id`、`qanchors`、`enriched_attributes`、`enriched_tags`、`enriched_taxonomy_attributes` |
747 760 | `results[].qanchors` | object | 与 ES `qanchors` 字段同结构,按语言键返回短语数组 |
748 761 | `results[].enriched_tags` | object | 与 ES `enriched_tags` 字段同结构,按语言键返回标签数组 |
749 762 | `results[].enriched_attributes` | array | 与 ES `enriched_attributes` nested 字段同结构,每项为 `{ "name", "value": { "zh"?: "...", "en"?: "..." } }` |
  763 +| `results[].enriched_taxonomy_attributes` | array | 与 ES `enriched_taxonomy_attributes` nested 字段同结构,每项为 `{ "name", "value": { "zh"?: [...], "en"?: [...] } }` |
750 764 | `results[].error` | string | 若该条处理失败(如 LLM 异常),会在此字段返回错误信息 |
751 765  
752 766 **错误响应**:
... ... @@ -760,6 +774,7 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \
760 774 -H "Content-Type: application/json" \
761 775 -d '{
762 776 "tenant_id": "163",
  777 + "analysis_kinds": ["content", "taxonomy"],
763 778 "items": [
764 779 {
765 780 "spu_id": "223167",
... ... @@ -773,4 +788,3 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \
773 788 ```
774 789  
775 790 ---
776   -
... ...
docs/搜索API对接指南-07-微服务接口(Embedding-Reranker-Translation).md
... ... @@ -444,7 +444,7 @@ curl "http://localhost:6006/health"
444 444  
445 445 - **Base URL**: Indexer 服务地址,如 `http://localhost:6004`
446 446 - **路径**: `POST /indexer/enrich-content`
447   -- **说明**: 根据商品标题批量生成 `qanchors`、`enriched_attributes`、`tags`,用于拼装 ES 文档。内部使用大模型(需配置 `DASHSCOPE_API_KEY`),支持多语言与 Redis 缓存;单次最多 50 条,建议批量调用以提升效率。
  447 +- **说明**: 根据商品标题批量生成 `qanchors`、`enriched_attributes`、`enriched_tags`、`enriched_taxonomy_attributes`,用于拼装 ES 文档。支持通过 `analysis_kinds` 选择执行 `content` / `taxonomy`;默认两者都执行。内部使用大模型(需配置 `DASHSCOPE_API_KEY`),支持多语言与 Redis 缓存;单次最多 50 条,建议批量调用以提升效率。
448 448  
449 449 请求/响应格式、示例及错误码见 [-05-索引接口(Indexer)](./搜索API对接指南-05-索引接口(Indexer).md#58-内容理解字段生成接口)。
450 450  
... ...
docs/缓存与Redis使用说明.md
... ... @@ -196,18 +196,25 @@ services:
196 196 - 配置项:
197 197 - `ANCHOR_CACHE_PREFIX = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")`
198 198 - `ANCHOR_CACHE_EXPIRE_DAYS = int(REDIS_CONFIG.get("anchor_cache_expire_days", 30))`
199   -- Key 构造函数:`_make_anchor_cache_key(title, target_lang, tenant_id)`
  199 +- Key 构造函数:`_make_analysis_cache_key(product, target_lang, analysis_kind)`
200 200 - 模板:
201 201  
202 202 ```text
203   -{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)}
  203 +{ANCHOR_CACHE_PREFIX}:{analysis_kind}:{prompt_contract_hash}:{target_lang}:{prompt_input_prefix}{md5(prompt_input)}
204 204 ```
205 205  
206 206 - 字段说明:
207 207 - `ANCHOR_CACHE_PREFIX`:默认 `"product_anchors"`,可通过 `.env` 中的 `REDIS_ANCHOR_CACHE_PREFIX`(若存在)间接配置到 `REDIS_CONFIG`;
208   - - `tenant_or_global`:`tenant_id` 去空白后的字符串,若为空则使用 `"global"`;
  208 + - `analysis_kind`:分析族,目前至少包括 `content` 与 `taxonomy`,两者缓存隔离;
  209 + - `prompt_contract_hash`:基于 system prompt、shared instruction、localized headers、result fields、user instruction template、schema cache version 等生成的短 hash;只要提示词或输出契约变化,缓存会自动失效;
209 210 - `target_lang`:内容理解输出语言,例如 `zh`;
210   - - `md5(title)`:对原始商品标题(UTF-8)做 MD5。
  211 + - `prompt_input_prefix + md5(prompt_input)`:对真正送入 prompt 的商品文本做前缀 + MD5;当前 prompt 输入来自 `title`、`brief`、`description` 的规范化拼接结果。
  212 +
  213 +设计原则:
  214 +
  215 +- 只让**实际影响 LLM 输出**的输入参与 key;
  216 +- 不让 `tenant_id`、`spu_id` 这类“结果归属信息”污染缓存;
  217 +- prompt 或 schema 变更时,不依赖人工清理 Redis,也能自然切换到新 key。
211 218  
212 219 ### 4.2 Value 与类型
213 220  
... ... @@ -229,6 +236,7 @@ services:
229 236 ```
230 237  
231 238 - 读取时通过 `json.loads(raw)` 还原为 `Dict[str, Any]`。
  239 +- `content` 与 `taxonomy` 的 value 结构会随各自 schema 不同而不同,但都会先通过统一的 normalize 逻辑再写缓存。
232 240  
233 241 ### 4.3 过期策略
234 242  
... ...
indexer/README.md
... ... @@ -8,7 +8,7 @@
8 8  
9 9 ### 1.1 系统角色划分
10 10  
11   -- **Java 索引程序(/home/tw/saas-server)**
  11 +- **Java 索引程序**
12 12 - 负责“**什么时候、对哪些 SPU 做索引**”(调度 & 触发)。
13 13 - 负责**商品/店铺/类目等基础数据同步**(写 MySQL)。
14 14 - 负责**多租户环境下的全量/增量索引调度**,但不再关心具体 doc 字段细节。
... ...
indexer/product_enrich.py
... ... @@ -151,6 +151,7 @@ if _missing_prompt_langs:
151 151 # 多值字段分隔:英文逗号、中文逗号、顿号,及历史约定的 ; | / 与空白
152 152 _MULTI_VALUE_FIELD_SPLIT_RE = re.compile(r"[,、,;|/\n\t]+")
153 153 _CORE_INDEX_LANGUAGES = ("zh", "en")
  154 +_DEFAULT_ANALYSIS_KINDS = ("content", "taxonomy")
154 155 _CONTENT_ANALYSIS_ATTRIBUTE_FIELD_MAP = (
155 156 ("tags", "enriched_tags"),
156 157 ("target_audience", "target_audience"),
... ... @@ -226,6 +227,7 @@ class AnalysisSchema:
226 227 markdown_table_headers: Dict[str, List[str]]
227 228 result_fields: Tuple[str, ...]
228 229 meaningful_fields: Tuple[str, ...]
  230 + cache_version: str = "v1"
229 231 field_aliases: Dict[str, Tuple[str, ...]] = field(default_factory=dict)
230 232 fallback_headers: Optional[List[str]] = None
231 233 quality_fields: Tuple[str, ...] = ()
... ... @@ -246,6 +248,7 @@ _ANALYSIS_SCHEMAS: Dict[str, AnalysisSchema] = {
246 248 markdown_table_headers=LANGUAGE_MARKDOWN_TABLE_HEADERS,
247 249 result_fields=_CONTENT_ANALYSIS_RESULT_FIELDS,
248 250 meaningful_fields=_CONTENT_ANALYSIS_MEANINGFUL_FIELDS,
  251 + cache_version="v2",
249 252 field_aliases=_CONTENT_ANALYSIS_FIELD_ALIASES,
250 253 quality_fields=_CONTENT_ANALYSIS_QUALITY_FIELDS,
251 254 ),
... ... @@ -255,6 +258,7 @@ _ANALYSIS_SCHEMAS: Dict[str, AnalysisSchema] = {
255 258 markdown_table_headers=TAXONOMY_LANGUAGE_MARKDOWN_TABLE_HEADERS,
256 259 result_fields=_TAXONOMY_ANALYSIS_RESULT_FIELDS,
257 260 meaningful_fields=_TAXONOMY_ANALYSIS_RESULT_FIELDS,
  261 + cache_version="v1",
258 262 fallback_headers=TAXONOMY_MARKDOWN_TABLE_HEADERS_EN,
259 263 ),
260 264 }
... ... @@ -267,6 +271,21 @@ def _get_analysis_schema(analysis_kind: str) -> AnalysisSchema:
267 271 return schema
268 272  
269 273  
  274 +def _normalize_analysis_kinds(
  275 + analysis_kinds: Optional[List[str]] = None,
  276 +) -> Tuple[str, ...]:
  277 + requested = _DEFAULT_ANALYSIS_KINDS if not analysis_kinds else tuple(analysis_kinds)
  278 + normalized: List[str] = []
  279 + seen = set()
  280 + for analysis_kind in requested:
  281 + schema = _get_analysis_schema(str(analysis_kind).strip())
  282 + if schema.name in seen:
  283 + continue
  284 + seen.add(schema.name)
  285 + normalized.append(schema.name)
  286 + return tuple(normalized)
  287 +
  288 +
270 289 def split_multi_value_field(text: Optional[str]) -> List[str]:
271 290 """将 LLM/业务中的多值字符串拆成短语列表(strip 后去空)。"""
272 291 if text is None:
... ... @@ -456,6 +475,7 @@ def _normalize_index_content_item(item: Dict[str, Any]) -> Dict[str, str]:
456 475 def build_index_content_fields(
457 476 items: List[Dict[str, Any]],
458 477 tenant_id: Optional[str] = None,
  478 + analysis_kinds: Optional[List[str]] = None,
459 479 ) -> List[Dict[str, Any]]:
460 480 """
461 481 高层入口:生成与 ES mapping 对齐的内容理解字段。
... ... @@ -464,6 +484,7 @@ def build_index_content_fields(
464 484 - `id` 或 `spu_id`
465 485 - `title`
466 486 - 可选 `brief` / `description` / `image_url`
  487 + - 可选 `analysis_kinds`,默认同时执行 `content` 与 `taxonomy`
467 488  
468 489 返回项结构:
469 490 - `id`
... ... @@ -477,6 +498,7 @@ def build_index_content_fields(
477 498 - `qanchors.{lang}` 为短语数组
478 499 - `enriched_tags.{lang}` 为标签数组
479 500 """
  501 + requested_analysis_kinds = _normalize_analysis_kinds(analysis_kinds)
480 502 normalized_items = [_normalize_index_content_item(item) for item in items]
481 503 if not normalized_items:
482 504 return []
... ... @@ -493,54 +515,57 @@ def build_index_content_fields(
493 515 }
494 516  
495 517 for lang in _CORE_INDEX_LANGUAGES:
496   - try:
497   - rows = analyze_products(
498   - products=normalized_items,
499   - target_lang=lang,
500   - batch_size=BATCH_SIZE,
501   - tenant_id=tenant_id,
502   - )
503   - except Exception as e:
504   - logger.warning("build_index_content_fields failed for lang=%s: %s", lang, e)
505   - for item in normalized_items:
506   - results_by_id[item["id"]].setdefault("error", str(e))
507   - continue
508   -
509   - for row in rows or []:
510   - item_id = str(row.get("id") or "").strip()
511   - if not item_id or item_id not in results_by_id:
512   - continue
513   - if row.get("error"):
514   - results_by_id[item_id].setdefault("error", row["error"])
  518 + if "content" in requested_analysis_kinds:
  519 + try:
  520 + rows = analyze_products(
  521 + products=normalized_items,
  522 + target_lang=lang,
  523 + batch_size=BATCH_SIZE,
  524 + tenant_id=tenant_id,
  525 + analysis_kind="content",
  526 + )
  527 + except Exception as e:
  528 + logger.warning("build_index_content_fields content enrichment failed for lang=%s: %s", lang, e)
  529 + for item in normalized_items:
  530 + results_by_id[item["id"]].setdefault("error", str(e))
515 531 continue
516   - _apply_index_content_row(results_by_id[item_id], row=row, lang=lang)
517   -
518   - try:
519   - taxonomy_rows = analyze_products(
520   - products=normalized_items,
521   - target_lang=lang,
522   - batch_size=BATCH_SIZE,
523   - tenant_id=tenant_id,
524   - analysis_kind="taxonomy",
525   - )
526   - except Exception as e:
527   - logger.warning(
528   - "build_index_content_fields taxonomy enrichment failed for lang=%s: %s",
529   - lang,
530   - e,
531   - )
532   - for item in normalized_items:
533   - results_by_id[item["id"]].setdefault("error", str(e))
534   - continue
535 532  
536   - for row in taxonomy_rows or []:
537   - item_id = str(row.get("id") or "").strip()
538   - if not item_id or item_id not in results_by_id:
539   - continue
540   - if row.get("error"):
541   - results_by_id[item_id].setdefault("error", row["error"])
  533 + for row in rows or []:
  534 + item_id = str(row.get("id") or "").strip()
  535 + if not item_id or item_id not in results_by_id:
  536 + continue
  537 + if row.get("error"):
  538 + results_by_id[item_id].setdefault("error", row["error"])
  539 + continue
  540 + _apply_index_content_row(results_by_id[item_id], row=row, lang=lang)
  541 +
  542 + if "taxonomy" in requested_analysis_kinds:
  543 + try:
  544 + taxonomy_rows = analyze_products(
  545 + products=normalized_items,
  546 + target_lang=lang,
  547 + batch_size=BATCH_SIZE,
  548 + tenant_id=tenant_id,
  549 + analysis_kind="taxonomy",
  550 + )
  551 + except Exception as e:
  552 + logger.warning(
  553 + "build_index_content_fields taxonomy enrichment failed for lang=%s: %s",
  554 + lang,
  555 + e,
  556 + )
  557 + for item in normalized_items:
  558 + results_by_id[item["id"]].setdefault("error", str(e))
542 559 continue
543   - _apply_index_taxonomy_row(results_by_id[item_id], row=row, lang=lang)
  560 +
  561 + for row in taxonomy_rows or []:
  562 + item_id = str(row.get("id") or "").strip()
  563 + if not item_id or item_id not in results_by_id:
  564 + continue
  565 + if row.get("error"):
  566 + results_by_id[item_id].setdefault("error", row["error"])
  567 + continue
  568 + _apply_index_taxonomy_row(results_by_id[item_id], row=row, lang=lang)
544 569  
545 570 return [results_by_id[item["id"]] for item in normalized_items]
546 571  
... ... @@ -613,9 +638,27 @@ def _make_analysis_cache_key(
613 638 analysis_kind: str,
614 639 ) -> str:
615 640 """构造缓存 key,仅由分析类型、prompt 实际输入文本内容与目标语言决定。"""
  641 + schema = _get_analysis_schema(analysis_kind)
616 642 prompt_input = _build_prompt_input_text(product)
617 643 h = hashlib.md5(prompt_input.encode("utf-8")).hexdigest()
618   - return f"{ANCHOR_CACHE_PREFIX}:{analysis_kind}:{target_lang}:{prompt_input[:4]}{h}"
  644 + prompt_contract = {
  645 + "schema_name": schema.name,
  646 + "cache_version": schema.cache_version,
  647 + "system_message": SYSTEM_MESSAGE,
  648 + "user_instruction_template": USER_INSTRUCTION_TEMPLATE,
  649 + "shared_instruction": schema.shared_instruction,
  650 + "assistant_headers": schema.get_headers(target_lang),
  651 + "result_fields": schema.result_fields,
  652 + "meaningful_fields": schema.meaningful_fields,
  653 + "field_aliases": schema.field_aliases,
  654 + }
  655 + prompt_contract_hash = hashlib.md5(
  656 + json.dumps(prompt_contract, ensure_ascii=False, sort_keys=True).encode("utf-8")
  657 + ).hexdigest()[:12]
  658 + return (
  659 + f"{ANCHOR_CACHE_PREFIX}:{analysis_kind}:{prompt_contract_hash}:"
  660 + f"{target_lang}:{prompt_input[:4]}{h}"
  661 + )
619 662  
620 663  
621 664 def _make_anchor_cache_key(
... ...
indexer/prompts.txt deleted
... ... @@ -1,30 +0,0 @@
1   -因为需要组织整个doc,我需要将当前的java程序迁移过来,项目路径在 /home/tw/saas-server
2   -程序相对路径 包括但不限于 module-shoplazza/src/main/java/com/hsyl/saas/module/shoplazza/service/index/ProductIndexServiceImpl.java
3   -请仔细阅读java相关代码,提取相关逻辑,特别是 翻译的相关字段
4   -
5   -
6   -
7   -
8   -
9   -架构说明:
10   -
11   -java索引程序职责:
12   -
13   -负责增量、全量的触发,调度。
14   -
15   -包括但不限于:
16   -1、索引结构调整成按tenant_id的结构,并翻译对应的语言shoplazza_shop_config表对应的新增字段primary_language,translate_to_en,translate_to_zh
17   -2、每晚上商品同步时,判断当前店铺主语言是什么,存入primary_language
18   -3、同步店匠的类目shoplazza_product_category
19   -4、加入MQ处理店匠批量导入商品并发太高,服务器承载不了的问题
20   -
21   -
22   -本模块:
23   -负责 msyql 基础数据 → 索引结构的doc (包括缓存)
24   -
25   -翻译接口: curl -X POST http://43.166.252.75:6006/translate -H "Content-Type: application/json" -d '{"text":"儿童小男孩女孩开学 100 天衬衫短袖 搞笑图案字母印花庆祝上衣","target_lang":"en","source_lang":"auto"}'
26   -
27   -java的组织doc的逻辑都需要迁移过来。
28   -
29   -当前项目,是直接将doc写入ES,这个功能也保留,但是,也要提供一个接口,输入完整的字段信息
30   -
tests/ci/test_service_api_contracts.py
... ... @@ -345,8 +345,13 @@ def test_indexer_build_docs_from_db_contract(indexer_client: TestClient):
345 345 def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch):
346 346 import indexer.product_enrich as process_products
347 347  
348   - def _fake_build_index_content_fields(items: List[Dict[str, str]], tenant_id: str | None = None):
  348 + def _fake_build_index_content_fields(
  349 + items: List[Dict[str, str]],
  350 + tenant_id: str | None = None,
  351 + analysis_kinds: List[str] | None = None,
  352 + ):
349 353 assert tenant_id == "162"
  354 + assert analysis_kinds == ["content", "taxonomy"]
350 355 return [
351 356 {
352 357 "id": p["spu_id"],
... ... @@ -358,6 +363,9 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch
358 363 "enriched_attributes": [
359 364 {"name": "enriched_tags", "value": {"zh": ["tag1"], "en": ["tag1"]}},
360 365 ],
  366 + "enriched_taxonomy_attributes": [
  367 + {"name": "Product Type", "value": {"zh": ["T恤"], "en": ["t-shirt"]}},
  368 + ],
361 369 }
362 370 for p in items
363 371 ]
... ... @@ -377,6 +385,7 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch
377 385 assert response.status_code == 200
378 386 data = response.json()
379 387 assert data["tenant_id"] == "162"
  388 + assert data["analysis_kinds"] == ["content", "taxonomy"]
380 389 assert data["total"] == 2
381 390 assert len(data["results"]) == 2
382 391 assert data["results"][0]["spu_id"] == "1001"
... ... @@ -388,6 +397,10 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch
388 397 "name": "enriched_tags",
389 398 "value": {"zh": ["tag1"], "en": ["tag1"]},
390 399 }
  400 + assert data["results"][0]["enriched_taxonomy_attributes"][0] == {
  401 + "name": "Product Type",
  402 + "value": {"zh": ["T恤"], "en": ["t-shirt"]},
  403 + }
391 404  
392 405  
393 406 def test_indexer_documents_contract(indexer_client: TestClient):
... ...
tests/test_product_enrich_partial_mode.py
... ... @@ -573,6 +573,40 @@ def test_anchor_cache_key_depends_on_product_input_not_identifiers():
573 573 assert key_a != key_c
574 574  
575 575  
  576 +def test_analysis_cache_key_isolated_by_analysis_kind():
  577 + product = {
  578 + "id": "1",
  579 + "title": "dress",
  580 + "brief": "soft cotton",
  581 + "description": "summer dress",
  582 + }
  583 +
  584 + content_key = product_enrich._make_analysis_cache_key(product, "zh", "content")
  585 + taxonomy_key = product_enrich._make_analysis_cache_key(product, "zh", "taxonomy")
  586 +
  587 + assert content_key != taxonomy_key
  588 +
  589 +
  590 +def test_analysis_cache_key_changes_when_prompt_contract_changes():
  591 + product = {
  592 + "id": "1",
  593 + "title": "dress",
  594 + "brief": "soft cotton",
  595 + "description": "summer dress",
  596 + }
  597 +
  598 + original_key = product_enrich._make_analysis_cache_key(product, "zh", "taxonomy")
  599 +
  600 + with mock.patch.object(
  601 + product_enrich,
  602 + "USER_INSTRUCTION_TEMPLATE",
  603 + "Please return JSON only. Language: {language}",
  604 + ):
  605 + changed_key = product_enrich._make_analysis_cache_key(product, "zh", "taxonomy")
  606 +
  607 + assert original_key != changed_key
  608 +
  609 +
576 610 def test_build_prompt_input_text_appends_brief_and_description_for_short_title():
577 611 product = {
578 612 "title": "T恤",
... ...