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 +7,7 @@
7 import asyncio 7 import asyncio
8 import re 8 import re
9 from fastapi import APIRouter, HTTPException 9 from fastapi import APIRouter, HTTPException
10 -from typing import Any, Dict, List, Optional 10 +from typing import Any, Dict, List, Literal, Optional
11 from pydantic import BaseModel, Field 11 from pydantic import BaseModel, Field
12 import logging 12 import logging
13 from sqlalchemy import text 13 from sqlalchemy import text
@@ -88,11 +88,20 @@ class EnrichContentItem(BaseModel): @@ -88,11 +88,20 @@ class EnrichContentItem(BaseModel):
88 88
89 class EnrichContentRequest(BaseModel): 89 class EnrichContentRequest(BaseModel):
90 """ 90 """
91 - 内容理解字段生成请求:根据商品标题批量生成 qanchors、enriched_attributes、tags 91 + 内容理解字段生成请求:根据商品标题批量生成 qanchors、enriched_attributes、tags、taxonomy attributes
92 供外部 indexer 在自行组织 doc 时调用,与翻译、向量化等微服务并列。 92 供外部 indexer 在自行组织 doc 时调用,与翻译、向量化等微服务并列。
93 """ 93 """
94 tenant_id: str = Field(..., description="租户 ID,用于请求路由与结果归属,不参与缓存键") 94 tenant_id: str = Field(..., description="租户 ID,用于请求路由与结果归属,不参与缓存键")
95 items: List[EnrichContentItem] = Field(..., description="待分析的 SPU 列表(spu_id + title,可附带 brief/description/image_url)") 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 @router.post("/reindex") 107 @router.post("/reindex")
@@ -440,20 +449,29 @@ async def build_docs_from_db(request: BuildDocsFromDbRequest): @@ -440,20 +449,29 @@ async def build_docs_from_db(request: BuildDocsFromDbRequest):
440 raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") 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 同步执行内容理解,返回与 ES mapping 对齐的字段结构。 458 同步执行内容理解,返回与 ES mapping 对齐的字段结构。
446 语言策略由 product_enrich 内部统一决定,路由层不参与。 459 语言策略由 product_enrich 内部统一决定,路由层不参与。
447 """ 460 """
448 from indexer.product_enrich import build_index_content_fields 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 return [ 468 return [
452 { 469 {
453 "spu_id": item["id"], 470 "spu_id": item["id"],
454 "qanchors": item["qanchors"], 471 "qanchors": item["qanchors"],
455 "enriched_attributes": item["enriched_attributes"], 472 "enriched_attributes": item["enriched_attributes"],
456 "enriched_tags": item["enriched_tags"], 473 "enriched_tags": item["enriched_tags"],
  474 + "enriched_taxonomy_attributes": item["enriched_taxonomy_attributes"],
457 **({"error": item["error"]} if item.get("error") else {}), 475 **({"error": item["error"]} if item.get("error") else {}),
458 } 476 }
459 for item in results 477 for item in results
@@ -463,15 +481,15 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]]) -> List[Dic @@ -463,15 +481,15 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]]) -> List[Dic
463 @router.post("/enrich-content") 481 @router.post("/enrich-content")
464 async def enrich_content(request: EnrichContentRequest): 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 - 外部 indexer 采用「微服务组合」方式自己组织 doc 时,可调用本接口获取 LLM 生成的 487 - 外部 indexer 采用「微服务组合」方式自己组织 doc 时,可调用本接口获取 LLM 生成的
470 锚文本与语义属性,再与翻译、向量化结果合并写入 ES。 488 锚文本与语义属性,再与翻译、向量化结果合并写入 ES。
471 - 与 /indexer/build-docs 解耦,避免 build-docs 因 LLM 耗时过长而阻塞;调用方可 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 try: 494 try:
477 if not request.items: 495 if not request.items:
@@ -497,11 +515,13 @@ async def enrich_content(request: EnrichContentRequest): @@ -497,11 +515,13 @@ async def enrich_content(request: EnrichContentRequest):
497 None, 515 None,
498 lambda: _run_enrich_content( 516 lambda: _run_enrich_content(
499 tenant_id=request.tenant_id, 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 return { 522 return {
504 "tenant_id": request.tenant_id, 523 "tenant_id": request.tenant_id,
  524 + "analysis_kinds": request.analysis_kinds,
505 "results": result, 525 "results": result,
506 "total": len(result), 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,13 +648,14 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
648 ### 5.8 内容理解字段生成接口 648 ### 5.8 内容理解字段生成接口
649 649
650 - **端点**: `POST /indexer/enrich-content` 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 ```json 655 ```json
656 { 656 {
657 "tenant_id": "170", 657 "tenant_id": "170",
  658 + "analysis_kinds": ["content", "taxonomy"],
658 "items": [ 659 "items": [
659 { 660 {
660 "spu_id": "223167", 661 "spu_id": "223167",
@@ -675,6 +676,7 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \ @@ -675,6 +676,7 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
675 | 参数 | 类型 | 必填 | 默认值 | 说明 | 676 | 参数 | 类型 | 必填 | 默认值 | 说明 |
676 |------|------|------|--------|------| 677 |------|------|------|--------|------|
677 | `tenant_id` | string | Y | - | 租户 ID。目前仅用于记录日志,不产生实际作用| 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 | `items` | array | Y | - | 待分析列表;**单次最多 50 条** | 680 | `items` | array | Y | - | 待分析列表;**单次最多 50 条** |
679 681
680 `items[]` 字段说明: 682 `items[]` 字段说明:
@@ -683,15 +685,18 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \ @@ -683,15 +685,18 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
683 |------|------|------|------| 685 |------|------|------|------|
684 | `spu_id` | string | Y | SPU ID,用于回填结果;目前仅用于记录日志,不产生实际作用| 686 | `spu_id` | string | Y | SPU ID,用于回填结果;目前仅用于记录日志,不产生实际作用|
685 | `title` | string | Y | 商品标题 | 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 - `tenant_id`、`spu_id` 只用于请求归属与结果回填,不参与缓存键。 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,6 +714,7 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
709 ```json 714 ```json
710 { 715 {
711 "tenant_id": "170", 716 "tenant_id": "170",
  717 + "analysis_kinds": ["content", "taxonomy"],
712 "total": 2, 718 "total": 2,
713 "results": [ 719 "results": [
714 { 720 {
@@ -725,6 +731,11 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \ @@ -725,6 +731,11 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
725 { "name": "enriched_tags", "value": { "zh": "纯棉" } }, 731 { "name": "enriched_tags", "value": { "zh": "纯棉" } },
726 { "name": "usage_scene", "value": { "zh": "日常" } }, 732 { "name": "usage_scene", "value": { "zh": "日常" } },
727 { "name": "enriched_tags", "value": { "en": "cotton" } } 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,7 +746,8 @@ curl -X POST "http://127.0.0.1:6004/indexer/build-docs-from-db" \
735 "enriched_tags": { 746 "enriched_tags": {
736 "en": ["dolls", "toys"] 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,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 | `results[].qanchors` | object | 与 ES `qanchors` 字段同结构,按语言键返回短语数组 | 760 | `results[].qanchors` | object | 与 ES `qanchors` 字段同结构,按语言键返回短语数组 |
748 | `results[].enriched_tags` | object | 与 ES `enriched_tags` 字段同结构,按语言键返回标签数组 | 761 | `results[].enriched_tags` | object | 与 ES `enriched_tags` 字段同结构,按语言键返回标签数组 |
749 | `results[].enriched_attributes` | array | 与 ES `enriched_attributes` nested 字段同结构,每项为 `{ "name", "value": { "zh"?: "...", "en"?: "..." } }` | 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 | `results[].error` | string | 若该条处理失败(如 LLM 异常),会在此字段返回错误信息 | 764 | `results[].error` | string | 若该条处理失败(如 LLM 异常),会在此字段返回错误信息 |
751 765
752 **错误响应**: 766 **错误响应**:
@@ -760,6 +774,7 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \ @@ -760,6 +774,7 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \
760 -H "Content-Type: application/json" \ 774 -H "Content-Type: application/json" \
761 -d '{ 775 -d '{
762 "tenant_id": "163", 776 "tenant_id": "163",
  777 + "analysis_kinds": ["content", "taxonomy"],
763 "items": [ 778 "items": [
764 { 779 {
765 "spu_id": "223167", 780 "spu_id": "223167",
@@ -773,4 +788,3 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \ @@ -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,7 +444,7 @@ curl "http://localhost:6006/health"
444 444
445 - **Base URL**: Indexer 服务地址,如 `http://localhost:6004` 445 - **Base URL**: Indexer 服务地址,如 `http://localhost:6004`
446 - **路径**: `POST /indexer/enrich-content` 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 请求/响应格式、示例及错误码见 [-05-索引接口(Indexer)](./搜索API对接指南-05-索引接口(Indexer).md#58-内容理解字段生成接口)。 449 请求/响应格式、示例及错误码见 [-05-索引接口(Indexer)](./搜索API对接指南-05-索引接口(Indexer).md#58-内容理解字段生成接口)。
450 450
docs/缓存与Redis使用说明.md
@@ -196,18 +196,25 @@ services: @@ -196,18 +196,25 @@ services:
196 - 配置项: 196 - 配置项:
197 - `ANCHOR_CACHE_PREFIX = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")` 197 - `ANCHOR_CACHE_PREFIX = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")`
198 - `ANCHOR_CACHE_EXPIRE_DAYS = int(REDIS_CONFIG.get("anchor_cache_expire_days", 30))` 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 ```text 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 - `ANCHOR_CACHE_PREFIX`:默认 `"product_anchors"`,可通过 `.env` 中的 `REDIS_ANCHOR_CACHE_PREFIX`(若存在)间接配置到 `REDIS_CONFIG`; 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 - `target_lang`:内容理解输出语言,例如 `zh`; 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 ### 4.2 Value 与类型 219 ### 4.2 Value 与类型
213 220
@@ -229,6 +236,7 @@ services: @@ -229,6 +236,7 @@ services:
229 ``` 236 ```
230 237
231 - 读取时通过 `json.loads(raw)` 还原为 `Dict[str, Any]`。 238 - 读取时通过 `json.loads(raw)` 还原为 `Dict[str, Any]`。
  239 +- `content` 与 `taxonomy` 的 value 结构会随各自 schema 不同而不同,但都会先通过统一的 normalize 逻辑再写缓存。
232 240
233 ### 4.3 过期策略 241 ### 4.3 过期策略
234 242
@@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
8 8
9 ### 1.1 系统角色划分 9 ### 1.1 系统角色划分
10 10
11 -- **Java 索引程序(/home/tw/saas-server)** 11 +- **Java 索引程序**
12 - 负责“**什么时候、对哪些 SPU 做索引**”(调度 & 触发)。 12 - 负责“**什么时候、对哪些 SPU 做索引**”(调度 & 触发)。
13 - 负责**商品/店铺/类目等基础数据同步**(写 MySQL)。 13 - 负责**商品/店铺/类目等基础数据同步**(写 MySQL)。
14 - 负责**多租户环境下的全量/增量索引调度**,但不再关心具体 doc 字段细节。 14 - 负责**多租户环境下的全量/增量索引调度**,但不再关心具体 doc 字段细节。
indexer/product_enrich.py
@@ -151,6 +151,7 @@ if _missing_prompt_langs: @@ -151,6 +151,7 @@ if _missing_prompt_langs:
151 # 多值字段分隔:英文逗号、中文逗号、顿号,及历史约定的 ; | / 与空白 151 # 多值字段分隔:英文逗号、中文逗号、顿号,及历史约定的 ; | / 与空白
152 _MULTI_VALUE_FIELD_SPLIT_RE = re.compile(r"[,、,;|/\n\t]+") 152 _MULTI_VALUE_FIELD_SPLIT_RE = re.compile(r"[,、,;|/\n\t]+")
153 _CORE_INDEX_LANGUAGES = ("zh", "en") 153 _CORE_INDEX_LANGUAGES = ("zh", "en")
  154 +_DEFAULT_ANALYSIS_KINDS = ("content", "taxonomy")
154 _CONTENT_ANALYSIS_ATTRIBUTE_FIELD_MAP = ( 155 _CONTENT_ANALYSIS_ATTRIBUTE_FIELD_MAP = (
155 ("tags", "enriched_tags"), 156 ("tags", "enriched_tags"),
156 ("target_audience", "target_audience"), 157 ("target_audience", "target_audience"),
@@ -226,6 +227,7 @@ class AnalysisSchema: @@ -226,6 +227,7 @@ class AnalysisSchema:
226 markdown_table_headers: Dict[str, List[str]] 227 markdown_table_headers: Dict[str, List[str]]
227 result_fields: Tuple[str, ...] 228 result_fields: Tuple[str, ...]
228 meaningful_fields: Tuple[str, ...] 229 meaningful_fields: Tuple[str, ...]
  230 + cache_version: str = "v1"
229 field_aliases: Dict[str, Tuple[str, ...]] = field(default_factory=dict) 231 field_aliases: Dict[str, Tuple[str, ...]] = field(default_factory=dict)
230 fallback_headers: Optional[List[str]] = None 232 fallback_headers: Optional[List[str]] = None
231 quality_fields: Tuple[str, ...] = () 233 quality_fields: Tuple[str, ...] = ()
@@ -246,6 +248,7 @@ _ANALYSIS_SCHEMAS: Dict[str, AnalysisSchema] = { @@ -246,6 +248,7 @@ _ANALYSIS_SCHEMAS: Dict[str, AnalysisSchema] = {
246 markdown_table_headers=LANGUAGE_MARKDOWN_TABLE_HEADERS, 248 markdown_table_headers=LANGUAGE_MARKDOWN_TABLE_HEADERS,
247 result_fields=_CONTENT_ANALYSIS_RESULT_FIELDS, 249 result_fields=_CONTENT_ANALYSIS_RESULT_FIELDS,
248 meaningful_fields=_CONTENT_ANALYSIS_MEANINGFUL_FIELDS, 250 meaningful_fields=_CONTENT_ANALYSIS_MEANINGFUL_FIELDS,
  251 + cache_version="v2",
249 field_aliases=_CONTENT_ANALYSIS_FIELD_ALIASES, 252 field_aliases=_CONTENT_ANALYSIS_FIELD_ALIASES,
250 quality_fields=_CONTENT_ANALYSIS_QUALITY_FIELDS, 253 quality_fields=_CONTENT_ANALYSIS_QUALITY_FIELDS,
251 ), 254 ),
@@ -255,6 +258,7 @@ _ANALYSIS_SCHEMAS: Dict[str, AnalysisSchema] = { @@ -255,6 +258,7 @@ _ANALYSIS_SCHEMAS: Dict[str, AnalysisSchema] = {
255 markdown_table_headers=TAXONOMY_LANGUAGE_MARKDOWN_TABLE_HEADERS, 258 markdown_table_headers=TAXONOMY_LANGUAGE_MARKDOWN_TABLE_HEADERS,
256 result_fields=_TAXONOMY_ANALYSIS_RESULT_FIELDS, 259 result_fields=_TAXONOMY_ANALYSIS_RESULT_FIELDS,
257 meaningful_fields=_TAXONOMY_ANALYSIS_RESULT_FIELDS, 260 meaningful_fields=_TAXONOMY_ANALYSIS_RESULT_FIELDS,
  261 + cache_version="v1",
258 fallback_headers=TAXONOMY_MARKDOWN_TABLE_HEADERS_EN, 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,6 +271,21 @@ def _get_analysis_schema(analysis_kind: str) -> AnalysisSchema:
267 return schema 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 def split_multi_value_field(text: Optional[str]) -> List[str]: 289 def split_multi_value_field(text: Optional[str]) -> List[str]:
271 """将 LLM/业务中的多值字符串拆成短语列表(strip 后去空)。""" 290 """将 LLM/业务中的多值字符串拆成短语列表(strip 后去空)。"""
272 if text is None: 291 if text is None:
@@ -456,6 +475,7 @@ def _normalize_index_content_item(item: Dict[str, Any]) -> Dict[str, str]: @@ -456,6 +475,7 @@ def _normalize_index_content_item(item: Dict[str, Any]) -> Dict[str, str]:
456 def build_index_content_fields( 475 def build_index_content_fields(
457 items: List[Dict[str, Any]], 476 items: List[Dict[str, Any]],
458 tenant_id: Optional[str] = None, 477 tenant_id: Optional[str] = None,
  478 + analysis_kinds: Optional[List[str]] = None,
459 ) -> List[Dict[str, Any]]: 479 ) -> List[Dict[str, Any]]:
460 """ 480 """
461 高层入口:生成与 ES mapping 对齐的内容理解字段。 481 高层入口:生成与 ES mapping 对齐的内容理解字段。
@@ -464,6 +484,7 @@ def build_index_content_fields( @@ -464,6 +484,7 @@ def build_index_content_fields(
464 - `id` 或 `spu_id` 484 - `id` 或 `spu_id`
465 - `title` 485 - `title`
466 - 可选 `brief` / `description` / `image_url` 486 - 可选 `brief` / `description` / `image_url`
  487 + - 可选 `analysis_kinds`,默认同时执行 `content` 与 `taxonomy`
467 488
468 返回项结构: 489 返回项结构:
469 - `id` 490 - `id`
@@ -477,6 +498,7 @@ def build_index_content_fields( @@ -477,6 +498,7 @@ def build_index_content_fields(
477 - `qanchors.{lang}` 为短语数组 498 - `qanchors.{lang}` 为短语数组
478 - `enriched_tags.{lang}` 为标签数组 499 - `enriched_tags.{lang}` 为标签数组
479 """ 500 """
  501 + requested_analysis_kinds = _normalize_analysis_kinds(analysis_kinds)
480 normalized_items = [_normalize_index_content_item(item) for item in items] 502 normalized_items = [_normalize_index_content_item(item) for item in items]
481 if not normalized_items: 503 if not normalized_items:
482 return [] 504 return []
@@ -493,54 +515,57 @@ def build_index_content_fields( @@ -493,54 +515,57 @@ def build_index_content_fields(
493 } 515 }
494 516
495 for lang in _CORE_INDEX_LANGUAGES: 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 continue 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 continue 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 return [results_by_id[item["id"]] for item in normalized_items] 570 return [results_by_id[item["id"]] for item in normalized_items]
546 571
@@ -613,9 +638,27 @@ def _make_analysis_cache_key( @@ -613,9 +638,27 @@ def _make_analysis_cache_key(
613 analysis_kind: str, 638 analysis_kind: str,
614 ) -> str: 639 ) -> str:
615 """构造缓存 key,仅由分析类型、prompt 实际输入文本内容与目标语言决定。""" 640 """构造缓存 key,仅由分析类型、prompt 实际输入文本内容与目标语言决定。"""
  641 + schema = _get_analysis_schema(analysis_kind)
616 prompt_input = _build_prompt_input_text(product) 642 prompt_input = _build_prompt_input_text(product)
617 h = hashlib.md5(prompt_input.encode("utf-8")).hexdigest() 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 def _make_anchor_cache_key( 664 def _make_anchor_cache_key(
indexer/prompts.txt deleted
@@ -1,30 +0,0 @@ @@ -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,8 +345,13 @@ def test_indexer_build_docs_from_db_contract(indexer_client: TestClient):
345 def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch): 345 def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch):
346 import indexer.product_enrich as process_products 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 assert tenant_id == "162" 353 assert tenant_id == "162"
  354 + assert analysis_kinds == ["content", "taxonomy"]
350 return [ 355 return [
351 { 356 {
352 "id": p["spu_id"], 357 "id": p["spu_id"],
@@ -358,6 +363,9 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch @@ -358,6 +363,9 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch
358 "enriched_attributes": [ 363 "enriched_attributes": [
359 {"name": "enriched_tags", "value": {"zh": ["tag1"], "en": ["tag1"]}}, 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 for p in items 370 for p in items
363 ] 371 ]
@@ -377,6 +385,7 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch @@ -377,6 +385,7 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch
377 assert response.status_code == 200 385 assert response.status_code == 200
378 data = response.json() 386 data = response.json()
379 assert data["tenant_id"] == "162" 387 assert data["tenant_id"] == "162"
  388 + assert data["analysis_kinds"] == ["content", "taxonomy"]
380 assert data["total"] == 2 389 assert data["total"] == 2
381 assert len(data["results"]) == 2 390 assert len(data["results"]) == 2
382 assert data["results"][0]["spu_id"] == "1001" 391 assert data["results"][0]["spu_id"] == "1001"
@@ -388,6 +397,10 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch @@ -388,6 +397,10 @@ def test_indexer_enrich_content_contract(indexer_client: TestClient, monkeypatch
388 "name": "enriched_tags", 397 "name": "enriched_tags",
389 "value": {"zh": ["tag1"], "en": ["tag1"]}, 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 def test_indexer_documents_contract(indexer_client: TestClient): 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,6 +573,40 @@ def test_anchor_cache_key_depends_on_product_input_not_identifiers():
573 assert key_a != key_c 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 def test_build_prompt_input_text_appends_brief_and_description_for_short_title(): 610 def test_build_prompt_input_text_appends_brief_and_description_for_short_title():
577 product = { 611 product = {
578 "title": "T恤", 612 "title": "T恤",