Commit 5aaf0c7d5291f2e7e4f9702de54a722024cb87c3
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` 组合场景及缓存隔离测试
Showing
9 changed files
with
202 additions
and
100 deletions
Show diff stats
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 |
indexer/README.md
| @@ -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恤", |