From cf9dff3add39ded71529ed17a7e86f53decdef9e Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Wed, 16 Sep 2026 17:43:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-recommend):=20=E4=BF=9D=E9=9A=9C?= =?UTF-8?q?=E6=90=AD=E9=85=8D=E5=AE=8C=E6=95=B4=E6=80=A7=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=87=AA=E5=8A=A8=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/AI推荐搭配完整性保障_2b3f5eda.md | 163 ++++++++++ README.md | 11 +- internal/service/airecommend.go | 97 +++++- internal/service/airecommend_test.go | 27 ++ pkg/tokenhub/outfit_recommend.go | 283 +++++++++++++++++- pkg/tokenhub/outfit_recommend_test.go | 266 ++++++++++++++++ 接口文档.md | 7 +- 7 files changed, 829 insertions(+), 25 deletions(-) create mode 100644 .codebuddy/plans/AI推荐搭配完整性保障_2b3f5eda.md create mode 100644 internal/service/airecommend_test.go create mode 100644 pkg/tokenhub/outfit_recommend_test.go diff --git a/.codebuddy/plans/AI推荐搭配完整性保障_2b3f5eda.md b/.codebuddy/plans/AI推荐搭配完整性保障_2b3f5eda.md new file mode 100644 index 0000000..06e8e3b --- /dev/null +++ b/.codebuddy/plans/AI推荐搭配完整性保障_2b3f5eda.md @@ -0,0 +1,163 @@ +--- +name: AI推荐搭配完整性保障 +overview: 修复 AI 穿搭推荐可能只输出「外套 + 下装」等不完整搭配的问题:prompt 写死完整性规则,服务端新增完整性校验(缺失品类/冲突品类),不满足时带纠偏提示重试一次,仍不满足则由服务端从衣橱确定性补齐并标注「系统补齐」。 +todos: + - id: tokenhub-completeness + content: 用 [skill:lsp-code-analysis] 核对引用点后,实现完整性判定/同品类去重/确定性自动补齐与纠偏提示,并强化提示词 + status: completed + - id: tokenhub-tests + content: 新增 pkg/tokenhub 单测:规则判定、冲突剔除、补齐确定性与提示词拼接 + status: completed + dependencies: + - tokenhub-completeness + - id: service-orchestration + content: 改造 airecommend.generate:带纠偏提示重试一次(含时间预算保护)并在仍不满足时服务端补齐,补充日志 + status: completed + dependencies: + - tokenhub-completeness + - id: regression-verify + content: 跑通 go build、go vet 与全量 go test 回归,确认额度/失败语义与接口契约未变 + status: completed + dependencies: + - tokenhub-tests + - service-orchestration + - id: sync-ai-docs + content: 同步接口文档 5.6 与 README:搭配完整性规则、重试与自动补齐说明 + status: completed + dependencies: + - service-orchestration +--- + +## 产品概述 + +修复 AI 穿搭推荐结果不完整的问题:当前会生成「只有外套 + 裤子」这类缺少上装/内搭的搭配,用户拿到的是穿不出去的不完整方案。需要在提示词与服务端双重保障下,确保推荐结果是一套完整可穿的搭配。 + +## 核心功能 + +- **完整性硬规则(按衣橱实际存在的品类判定)**: +- 必须包含「上装」或「连衣裙」二者之一(衣橱存在其中任一时) +- 选择「外套」时不得孤立:必须同时搭配「上装」或「连衣裙」 +- 未选择「连衣裙」且衣橱存在「下装」时,必须包含「下装」 +- 衣橱存在「鞋」时必须包含「鞋」;「配饰」为可选点缀,不强制 +- 选择「连衣裙」时不再搭配「下装」(二者互斥) +- **模型输出不满足时的处理**:先带「缺上装」等纠偏提示重试一次模型调用;仍不满足则由服务端从衣橱自动补齐缺失品类(补齐单品标注「系统补齐」)、并剔除冲突项,不判定任务失败、不浪费用户 AI 额度 +- **同品类唯一**:同一品类最多 1 件(超出保留首件),与既有提示词规则一致 +- **补齐确定性**:同输入必得同输出(同风格优先 → 主色一致优先 → 最近上传优先),便于线上排查与单测 +- **体验保持**:接口出入参、额度计费、失败退额度语义、前端轮询方式均不变;用户最终看到的是完整搭配而非卡单或失败 + +## 技术栈 + +- Go + Gin + GORM(沿用现有分层:`pkg/tokenhub` 放提示词与纯校验/补齐函数,`internal/service` 负责编排、日志与落库),不新增依赖、不改数据库结构、不改 DTO 契约 +- 测试:Go 标准测试(`pkg/tokenhub` 目前无测试文件,新增 `outfit_recommend_test.go`),纯函数可直接断言,无需 sqlmock/网络 + +## 实现方案 + +### 核心思路 + +在 `pkg/tokenhub` 补齐「完整性规则 + 纠偏提示 + 确定性自动补齐」三件纯函数能力,并把完整性要求写进提示词;在 `internal/service/airecommend.go` 的 `generate` 中编排为「调用 → 校验(含同品类去重)→ 不满足则带纠偏提示重试一次 → 仍不满足则服务端补齐」,形成提示词与服务端双重保障。 + +```mermaid +flowchart LR + A[调模型生成搭配] --> B[ValidateClothesIDs 过滤幻觉/纠品类] + B --> C[NormalizeCategories 同品类去重] + C --> D{完整性检查
CompletenessIssues} + D -->|完整| G[落库待保存穿搭] + D -->|缺品类/冲突| E[带纠偏提示重试一次模型] + E --> F{再检查} + F -->|完整| G + F -->|仍不完整| H[ApplyAutoComplete
从衣橱补齐缺失品类 + 剔除冲突] + H --> G +``` + +### 关键决策与权衡 + +1. **规则放在 `pkg/tokenhub` 而非 service**:完整性判定只依赖「推荐结果 + 衣橱列表」,是纯函数,放 tokenhub 可与提示词同源、便于单测;service 只做编排。 +2. **重试只 1 次且带时间预算检查**:`aiRecommendRunTimeout` 维持 3 分钟不变(避免扩大前端等待 SLA、不必改文档中的轮询上限);调用前用 `ctx.Deadline()` 判断剩余预算,剩余不足(< 60s)时跳过模型重试、直接进入服务端补齐路径,避免因重试导致任务超时失败(客户端单次调用上限 120s)。 +3. **自动补齐而非失败**(已与用户确认):补齐是纯内存操作、确定性排序,保证「同输入同输出」,并把补齐品类写进 `reason`(如「系统按规则补齐:原推荐缺少上装」),便于用户理解与线上排查。 +4. **补齐优先级**:同风格(与已选单品风格众数一致)→ 主色与已选主色众数一致 → 视觉冲击力接近 → ID 最大(最近上传),全部为确定性排序键,不做随机。 +5. **不扩权**:不改 `category_main` 分类逻辑、不改前端展示、不新增数据库列、不改额度计费;`RecommendLLMResponse`/`AIRecommendCreateResponse` 结构保持兼容。 + +### 性能与可靠性 + +- 最坏情况由 1 次模型调用变为 2 次(仅当首次结果不完整/无有效单品时触发),有预算检查兜底,任务总耗时不超过既有 3 分钟上限;补齐为 O(衣橱件数) 的单次遍历,可忽略。 +- 失败路径完全复用既有 `finishFailed`(退额度 + 写失败流水),新增逻辑不改变错误码与额度语义。 +- 提示词注入防护:纠偏提示仅由固定枚举品类名拼装(不含任何用户输入),现有 `sanitizePromptField` 逻辑不变。 + +## 架构设计 + +沿用现有分层与调用链,改动集中在两处: + +```mermaid +flowchart TD + subgraph service + A[AIRecommendService.generate] --> B[tokenhub.Client.GenerateOutfitRecommendation] + A --> C[ValidateClothesIDs / NormalizeCategories] + A --> D[CompletenessIssues 判定] + A --> E[ApplyAutoComplete 补齐] + A --> F[落库 Outfit + OutfitItem + 流水] + end + subgraph pkg/tokenhub + B --> P[buildRecommendPrompt 含完整性规则与纠偏段] + D --> Q[完整性规则纯函数] + E --> Q + E --> R[确定性补齐排序] + end +``` + +## 目录结构 + +``` +zt-closet-backend/ +├── pkg/tokenhub/ +│ ├── outfit_recommend.go # [MODIFY] 提示词补充完整性硬规则与「上次输出问题」纠偏段;GenerateOutfitRecommendation 增加 retryHint 参数; +│ │ # 新增 NormalizeCategories(同品类去重)、CompletenessIssues(缺失/冲突判定)、 +│ │ # ApplyAutoComplete(确定性补齐+剔冲突)、BuildCorrectionHint 与严格选品提示常量 +│ └── outfit_recommend_test.go # [NEW] 规则判定/冲突/去重/补齐确定性/提示词拼接单测 +├── internal/service/ +│ └── airecommend.go # [MODIFY] generate 编排:校验后不满足则带提示重试一次(含剩余时间预算检查), +│ # 仍不满足则服务端补齐;新增结构化日志;落库/额度/错误码语义不变 +├── 接口文档.md # [MODIFY] 5.6 AI 推荐模块业务逻辑补充「搭配完整性保障(重试+服务端补齐)」,第 4 条输入说明与轮询说明保持一致 +└── README.md # [MODIFY] 「### 6. AI 推荐」处理流程补充完整性规则与补齐说明 +``` + +## 关键代码结构 + +``` +// pkg/tokenhub/outfit_recommend.go +// GenerateOutfitRecommendation 新增 retryHint 参数(为空表示首次调用;非空时作为「上次输出问题」注入 prompt)。 +func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, + clothes []mysqlmodel.Clothes, scene, retryHint string) (*RecommendLLMResponse, error) + +// NormalizeCategories 同一品类最多保留 1 件(保留首次出现),返回被移除的品类。 +func (r *RecommendLLMResponse) NormalizeCategories() (removed []string) + +// CompletenessIssues 按衣橱实际存在的品类判定,返回缺失品类与冲突品类;两者皆空表示搭配完整。 +// 规则:内层(上装或连衣裙)必选;外套不可孤立;未选连衣裙时下装必需;衣橱有鞋则鞋必选;配饰可选。 +func (r *RecommendLLMResponse) CompletenessIssues(clothes []mysqlmodel.Clothes) (missing []string, conflicts []string) + +// ApplyAutoComplete 确定性补齐缺失品类并剔除冲突项(如连衣裙+下装),返回新增与移除的品类。 +func (r *RecommendLLMResponse) ApplyAutoComplete(clothes []mysqlmodel.Clothes) (added []string, removed []string) + +// BuildCorrectionHint 生成纠偏提示文本(仅由固定品类枚举拼装,不含用户输入);无问题时返回空串。 +func BuildCorrectionHint(missing, conflicts []string) string + +// CorrectionHintStrictSelection 首次输出无任何有效单品时的兜底纠偏提示(严格从衣橱选品)。 +const CorrectionHintStrictSelection = "..." +``` + +## 实现要点 + +- 判定口径:衣橱品类取自 `clothes[].CategoryMain` 且仅统计 6 类(`outfitCategories`);衣橱不含「上装/连衣裙」时不强制内层;衣橱不含「下装/鞋」时不强制对应品类(避免"强制补齐不存在品类"导致空结果)。 +- 补齐实现:候选 = 该品类中未被选中的衣物;排序键依次为「风格==已选风格众数」「主色==已选主色众数」「视觉冲击力接近已选众数」「ID 降序」;补齐项 `Type/ClothesUUID` 取衣橱真实值,`Reason` 标注系统补齐;补齐后再跑一次 `CompletenessIssues` 校验(防御性),并限制最多补齐 3 件(上装/连衣裙、下装、鞋)。 +- 重试编排:仅当「无有效单品」或「不完整」时重试一次;重试结果需再次 `ValidateClothesIDs`,且仅在 `len(Items) > 0` 时替换首次结果(防止重试反而变空);重试失败(网络/超时)仅告警,继续走原有逻辑与补齐。 +- 日志:沿用 `slog.*Context`,重试打 Info(含 `user_id/task_id/missing/conflicts/remaining_budget`),补齐打 Warn(含 `added_categories/removed_categories`),不打印衣橱明细与用户隐私字段。 +- 兼容性:`GenerateOutfitRecommendation` 全项目仅 1 处调用(`internal/service/airecommend.go`),签名调整安全;`ValidateClothesIDs` 行为不变,新增方法不破坏既有契约;`ResultContent` 流水落库的是补齐后的最终结果,天然可审计。 +- 边界(不做):不改提示词中的运势/场景逻辑与 JSON Schema 结构;不改衣物分类;不改前端轮询与展示;不新增数据库列;重试不额外扣 AI 额度。 + +## Agent Extensions + +### Skill + +- **lsp-code-analysis** +- Purpose: 在调整 `GenerateOutfitRecommendation` 签名与新增 `RecommendLLMResponse` 方法前,用语义分析确认全部引用点(调用方、字段读取、既有测试),并核对 `NormalizeCategories/CompletenessIssues/ApplyAutoComplete/BuildCorrectionHint` 等新命名在 `pkg/tokenhub` 内无冲突 +- Expected outcome: 输出完整引用清单与受影响文件列表,确保签名调整无遗漏、无编译期破坏,新增导出符号命名唯一 \ No newline at end of file diff --git a/README.md b/README.md index 660a8b1..d9aa6fa 100644 --- a/README.md +++ b/README.md @@ -388,11 +388,12 @@ flowchart TD 2. **提交幂等**:同一用户已有任务在执行时直接返回该任务的 `task_id`,不会重复扣额度;活跃位 TTL 4 分钟,略长于任务超时 3 分钟,避免任务未收尾就被放行重复提交。 3. **输入**:当前用户**处理成功(4)** 的全部衣物(分页遍历)+ 由生日推算的星座 + 今日运势(无生日/运势时降级为仅星座或空)。 4. **扣额与回退**:**提交时**扣减 AI 额度 1 点;衣橱为空、模型调用失败、无有效单品、落库失败、任务超时等任一情况均**统一回退额度**并写失败流水(回退与写流水使用不受任务取消影响的 context,避免断连时收尾失败)。 -5. **结果校验**:校正模型返回的衣物 ID(过滤幻觉),无有效单品则失败回退。 -6. **落库**:生成穿搭以 `source=2 AI`、`status=3 待用户保存` 入库(私有、待审核);明细落库失败则删除穿搭 + 回退额度。 -7. **结果获取**:前端轮询 `GET /ai-recommend/status?task_id=...`(建议 2 秒一次、上限 90 次);`status=2` 取 `result` 渲染,`status=3` 读 `error_msg` 提示用户。任务快照在 Redis 保留 15 分钟,过期返回 `90402`。 -8. **保存转正**:仅 `status=3` 可保存(重复保存幂等),保存仅将状态 3→1,**不扣分享额度**;后续公开到社区仍走 Public 流程扣搭配分享额度。 -9. **自动清理**:未被保存的 `status=3` 穿搭超过 **7 天**,由后台定时任务(启动即执行一次,之后每小时一次,单批 500 条)连同明细一起软删除,避免长期堆积;清理后列表与详情立即不可见。 +5. **结果校验**:校正模型返回的衣物 ID(过滤幻觉)、纠正品类、同品类去重(每类最多 1 件)。 +6. **搭配完整性保障**:按衣橱实际存在的品类校验——必须含「上装」或「连衣裙」(**外套不可孤立**)、未选连衣裙时必须有下装、衣橱有鞋必须有鞋、配饰可选、连衣裙与下装互斥;不满足时**带纠偏提示重试一次模型**(重试前检查剩余时间预算,不足则跳过),仍不满足则由服务端从衣橱按「同风格 → 同主色 → 视觉冲击力接近 → 最近上传」的确定性规则**自动补齐**(`reason` 标注"系统按搭配完整性规则补齐"),避免出现"只有外套+下装"这类不完整搭配。 +7. **落库**:生成穿搭以 `source=2 AI`、`status=3 待用户保存` 入库(私有、待审核);明细落库失败则删除穿搭 + 回退额度。 +8. **结果获取**:前端轮询 `GET /ai-recommend/status?task_id=...`(建议 2 秒一次、上限 90 次);`status=2` 取 `result` 渲染,`status=3` 读 `error_msg` 提示用户。任务快照在 Redis 保留 15 分钟,过期返回 `90402`。 +9. **保存转正**:仅 `status=3` 可保存(重复保存幂等),保存仅将状态 3→1,**不扣分享额度**;后续公开到社区仍走 Public 流程扣搭配分享额度。 +10. **自动清理**:未被保存的 `status=3` 穿搭超过 **7 天**,由后台定时任务(启动即执行一次,之后每小时一次,单批 500 条)连同明细一起软删除,避免长期堆积;清理后列表与详情立即不可见。 ### 7. 通知触发规则 diff --git a/internal/service/airecommend.go b/internal/service/airecommend.go index 20dfe03..49c75ba 100644 --- a/internal/service/airecommend.go +++ b/internal/service/airecommend.go @@ -28,6 +28,10 @@ const ( // aiRecommendCleanupTimeout 任务收尾(退额度 / 写流水 / 释放活跃位)的超时。 aiRecommendCleanupTimeout = 15 * time.Second + + // aiRecommendRetryMinBudget 结果不完整时发起模型重试所需的最小剩余预算。 + // 大模型单次调用上限 120s,预算不足时跳过重试、直接走服务端补齐,避免重试把任务拖到整体超时失败。 + aiRecommendRetryMinBudget = 90 * time.Second ) // AIRecommendService AI 穿搭推荐业务服务。 @@ -291,19 +295,24 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID // 3. 调用大模型生成推荐。 requestTime := nowUnix() client := tokenhub.NewClient() - result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene) + result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene, "") if gerr != nil { slog.ErrorContext(ctx, "generate outfit recommendation failed", "user_id", userID, "task_id", taskID, "error", gerr) return nil, errcode.AIRecommendFailed.WithDetails("大模型调用失败") } result.ValidateClothesIDs(clothes) + result.NormalizeCategories() + + // 4. 搭配完整性保障:缺关键品类时带纠偏提示重试一次模型,仍不满足则由服务端从衣橱补齐, + // 避免出现「只有外套 + 下装」这类穿不出去的搭配。 + result = s.completeOutfit(ctx, client, taskID, userID, zodiac, fortune, clothes, request.Scene, result) if len(result.Items) == 0 { slog.WarnContext(ctx, "ai recommend generate: no valid items after validation", "user_id", userID, "task_id", taskID) return nil, errcode.AIRecommendFailed.WithDetails("未生成有效搭配") } - // 4. 落库为待用户保存的 AI 穿搭。 + // 5. 落库为待用户保存的 AI 穿搭。 scene := result.Scene if scene == "" { scene = request.Scene @@ -335,7 +344,7 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID return nil, errcode.OutfitCreateFailed } - // 5. 写成功流水(best-effort)。 + // 6. 写成功流水(best-effort)。 resultJSON, _ := json.Marshal(result) if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{ RequestID: genUUID(), @@ -349,7 +358,7 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err) } - // 6. 组装响应(衣物 UUID 来自校正后的 items)。 + // 7. 组装响应(衣物 UUID 来自校正后的 items)。 outfitItems := make([]dto.OutfitItem, 0, len(result.Items)) for _, it := range result.Items { outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID}) @@ -366,6 +375,86 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID }, nil } +// completeOutfit 保障搭配完整性:结果缺关键品类(或首次没有任何有效单品)时, +// 带纠偏提示重试一次模型调用;仍不满足则由服务端按确定性规则从衣橱补齐缺失品类并剔除冲突项。 +// +// 设计取舍: +// - 只重试一次,避免放大时延与模型成本; +// - 重试前检查任务剩余预算(客户端单次调用上限 120s),预算不足直接走服务端补齐; +// - 重试失败或重试结果反而为空时,保留首次结果继续补齐,不让用户因为"纠偏"而拿不到搭配。 +func (s *AIRecommendService) completeOutfit(ctx context.Context, client *tokenhub.Client, taskID string, userID int64, + zodiac string, fortune *tokenhub.FortuneItem, clothes []mysqlmodel.Clothes, scene string, + result *tokenhub.RecommendLLMResponse) *tokenhub.RecommendLLMResponse { + + missing, conflicts := result.CompletenessIssues(clothes) + if len(result.Items) > 0 && len(missing) == 0 && len(conflicts) == 0 { + return result + } + + if !hasRetryBudget(ctx) { + slog.WarnContext(ctx, "ai recommend: skip model retry, remaining budget insufficient", + "user_id", userID, "task_id", taskID, "items", len(result.Items), + "missing", missing, "conflicts", conflicts) + return s.autoCompleteOutfit(ctx, taskID, userID, result, clothes) + } + + hint := tokenhub.BuildCorrectionHint(missing, conflicts) + if len(result.Items) == 0 { + hint = tokenhub.CorrectionHintStrictSelection + } + slog.InfoContext(ctx, "ai recommend: outfit incomplete, retry with correction hint", + "user_id", userID, "task_id", taskID, "items", len(result.Items), + "missing", missing, "conflicts", conflicts) + + retry, rerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, scene, hint) + if rerr != nil { + slog.WarnContext(ctx, "ai recommend: model retry failed, fallback to auto complete", + "user_id", userID, "task_id", taskID, "error", rerr) + return s.autoCompleteOutfit(ctx, taskID, userID, result, clothes) + } + retry.ValidateClothesIDs(clothes) + retry.NormalizeCategories() + if len(retry.Items) == 0 { + slog.WarnContext(ctx, "ai recommend: model retry returned no valid item, keep first result", + "user_id", userID, "task_id", taskID) + return s.autoCompleteOutfit(ctx, taskID, userID, result, clothes) + } + + retryMissing, retryConflicts := retry.CompletenessIssues(clothes) + if len(retryMissing) == 0 && len(retryConflicts) == 0 { + slog.InfoContext(ctx, "ai recommend: outfit complete after retry", + "user_id", userID, "task_id", taskID, "items", len(retry.Items)) + return retry + } + slog.WarnContext(ctx, "ai recommend: still incomplete after retry, auto complete", + "user_id", userID, "task_id", taskID, "missing", retryMissing, "conflicts", retryConflicts) + return s.autoCompleteOutfit(ctx, taskID, userID, retry, clothes) +} + +// autoCompleteOutfit 服务端补齐:从衣橱确定性挑选缺失品类单品并剔除冲突项(如「连衣裙 + 下装」)。 +// 仅在已有推荐结果(items 非空)时补齐,避免凭空构造整套搭配。 +func (s *AIRecommendService) autoCompleteOutfit(ctx context.Context, taskID string, userID int64, + result *tokenhub.RecommendLLMResponse, clothes []mysqlmodel.Clothes) *tokenhub.RecommendLLMResponse { + if len(result.Items) == 0 { + return result // 无基准结果:交由调用方按「未生成有效搭配」处理(额度退还)。 + } + added, removed := result.ApplyAutoComplete(clothes) + if len(added) > 0 || len(removed) > 0 { + slog.WarnContext(ctx, "ai recommend: outfit auto completed by server", + "user_id", userID, "task_id", taskID, "added_categories", added, "removed_categories", removed) + } + return result +} + +// hasRetryBudget 判断任务是否还有足够预算发起一次模型重试;无 deadline 时视为预算充足。 +func hasRetryBudget(ctx context.Context) bool { + deadline, ok := ctx.Deadline() + if !ok { + return true + } + return time.Until(deadline) >= aiRecommendRetryMinBudget +} + // ---- 内部辅助 ---- // processedClothes 获取用户已成功处理、可用于推荐的衣物。 diff --git a/internal/service/airecommend_test.go b/internal/service/airecommend_test.go new file mode 100644 index 0000000..fa7a6d4 --- /dev/null +++ b/internal/service/airecommend_test.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + "testing" + "time" +) + +// TestHasRetryBudget 模型重试预算判定: +// 无 deadline 视为充足;剩余不足 aiRecommendRetryMinBudget 时不允许再发起一次模型调用(改为服务端补齐)。 +func TestHasRetryBudget(t *testing.T) { + if !hasRetryBudget(context.Background()) { + t.Fatal("no deadline should be treated as budget available") + } + + short, cancelShort := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelShort() + if hasRetryBudget(short) { + t.Fatalf("60s remaining should be insufficient (min budget %s)", aiRecommendRetryMinBudget) + } + + enough, cancelEnough := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancelEnough() + if !hasRetryBudget(enough) { + t.Fatal("5m remaining should allow one model retry") + } +} diff --git a/pkg/tokenhub/outfit_recommend.go b/pkg/tokenhub/outfit_recommend.go index 68665b5..14f8594 100644 --- a/pkg/tokenhub/outfit_recommend.go +++ b/pkg/tokenhub/outfit_recommend.go @@ -12,6 +12,15 @@ import ( // outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。 var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"} +// 完整性规则涉及的品类常量(取值必须与 outfitCategories 一致)。 +const ( + categoryTop = "上装" + categoryBottom = "下装" + categoryDress = "连衣裙" + categoryCoat = "外套" + categoryShoes = "鞋" +) + // RecommendLLMItem 大模型返回的单品推荐。 type RecommendLLMItem struct { Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰 @@ -31,9 +40,11 @@ type RecommendLLMResponse struct { // GenerateOutfitRecommendation 调用混元生成今日穿搭推荐。 // scene 为用户自定义场景(前端 scene_type=2 时传入),为空表示由模型自行判断。 -func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene string) (*RecommendLLMResponse, error) { +// retryHint 为「上次输出不完整」时的服务端纠偏提示(由本包 BuildCorrectionHint / +// CorrectionHintStrictSelection 生成,不含用户输入);为空表示首次调用。 +func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene, retryHint string) (*RecommendLLMResponse, error) { messages := []Message{ - {Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes, scene)}, + {Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes, scene, retryHint)}, } responseFormat := &ResponseFormat{ @@ -70,7 +81,8 @@ func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string // buildRecommendPrompt 构造穿搭推荐 prompt。 // scene 为用户自定义场景(已做注入清洗):非空时作为硬约束写入 prompt,并指示模型在 scene 字段回填; // 为空时整段不出现,保持「由模型自行判断场景」的原行为。 -func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene string) string { +// retryHint 为本包生成的纠偏提示(不含用户输入);为空时整段不出现。 +func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene, retryHint string) string { clothesText := FormatClothesInfo(clothes) sceneSection := "" @@ -78,6 +90,11 @@ func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmo sceneSection = fmt.Sprintf("\n## 用户指定场景\n用户希望这套搭配用于「%s」,请优先围绕该场景挑选单品,并在 scene 字段回填该场景。\n", s) } + correctionSection := "" + if h := strings.TrimSpace(retryHint); h != "" { + correctionSection = fmt.Sprintf("\n## 上次输出问题(本次必须修正)\n%s\n", h) + } + return fmt.Sprintf(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。 ## 用户信息 @@ -96,14 +113,18 @@ func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmo 2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰: - 每个存在的品类最多推荐 1 件最合适的单品 - 衣橱中没有的品类,不在结果中输出,无需强制凑满 6 件 - - 连衣裙为「一件式」单品,若选择连衣裙则通常不再搭配下装,可搭配外套、鞋、配饰 - - 鞋为必备品类(若衣橱中存在),配饰为可选点缀 -3. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配 -4. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐 -5. 保证所选单品之间颜色、风格协调统一 -6. 为每件推荐单品撰写一句简洁的推荐理由 -7. 为整套穿搭拟定一个贴合风格与运势的好听标题 - +3. 完整性要求(判定依据为衣橱实际存在的品类,必须满足,否则视为无效输出): + - 必须包含「上装」或「连衣裙」二者之一(衣橱存在其中任一时)——不能只推荐外套或只推荐下装 + - 选择「外套」时必须同时搭配「上装」或「连衣裙」(外套是外层,不可孤立出现) + - 选择「连衣裙」时不再搭配「下装」(二者互斥) + - 未选择「连衣裙」且衣橱存在「下装」时,必须包含「下装」 + - 衣橱存在「鞋」时必须包含「鞋」;「配饰」为可选点缀 +4. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配 +5. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐 +6. 保证所选单品之间颜色、风格协调统一 +7. 为每件推荐单品撰写一句简洁的推荐理由 +8. 为整套穿搭拟定一个贴合风格与运势的好听标题 +%s ## 输出要求 必须严格按以下 JSON 结构输出,不要输出任何多余文字、解释或代码围栏: { @@ -118,10 +139,10 @@ func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmo 补充约束: - type 字段取值仅限「上装」「下装」「连衣裙」「外套」「鞋」「配饰」,与衣橱实际品类一一对应 - style 表示穿搭风格(如简约/复古/街头/甜美),scene 表示适合场景(如通勤/约会/休闲/运动),两者含义不同,必须分别输出 -- items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组 +- items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组;且必须满足上述「完整性要求」 - clothes_id 必须为整数,且必须是衣橱中真实存在的 ID - clothes_uuid 必须从「用户衣橱」信息中精确复制,且与 clothes_id 一一对应,严禁编造或改动 UUID`, - zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText, sceneSection) + zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText, sceneSection, correctionSection) } // buildRecommendSchema 构造 JSON Schema 要求大模型结构化输出。 @@ -221,6 +242,242 @@ func (r *RecommendLLMResponse) GetClothesIDs() []int64 { return ids } +// ---- 搭配完整性保障 ---- + +// CorrectionHintStrictSelection 首次输出没有任何有效单品时的纠偏提示(要求严格从衣橱选品)。 +const CorrectionHintStrictSelection = "上一次输出没有任何有效单品。请严格从「用户衣橱」中选择衣物," + + "clothes_id 与 clothes_uuid 必须与衣橱中真实存在的衣物完全一致,不得虚构或使用未列出的衣物。" + +// BuildCorrectionHint 生成重试时的纠偏提示:仅由固定品类枚举拼装(不含任何用户输入);无问题时返回空串。 +func BuildCorrectionHint(missing, conflicts []string) string { + if len(missing) == 0 && len(conflicts) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("上一次输出不符合搭配完整性规则,本次必须修正:") + if len(missing) > 0 { + sb.WriteString("\n- 必须补充以下品类(每类 1 件,且必须选自用户衣橱):" + strings.Join(missing, "、")) + } + if len(conflicts) > 0 { + sb.WriteString("\n- 以下品类与已选单品冲突,必须移除:" + strings.Join(conflicts, "、")) + } + sb.WriteString("\n- 其余单品可沿用上一次的选择,但必须同时满足「## 搭配规则」的全部要求。") + return sb.String() +} + +// NormalizeCategories 同一品类最多保留 1 件(保留首次出现),返回被移除的品类。 +// 与提示词「每个存在的品类最多推荐 1 件」保持一致,避免模型重复给出同类单品造成搭配冗余。 +func (r *RecommendLLMResponse) NormalizeCategories() (removed []string) { + seen := make(map[string]bool, len(r.Items)) + kept := make([]RecommendLLMItem, 0, len(r.Items)) + for _, item := range r.Items { + if item.Type != "" { + if seen[item.Type] { + removed = append(removed, item.Type) + continue + } + seen[item.Type] = true + } + kept = append(kept, item) + } + r.Items = kept + return removed +} + +// CompletenessIssues 按衣橱实际存在的品类判定搭配完整性,返回缺失品类与冲突品类;两者皆空表示搭配完整。 +// +// 规则(衣橱中不存在的品类不强制,避免要求补齐一个衣橱里根本没有的品类): +// 1. 内层必选(含「外套不可孤立」):结果必须包含「上装」或「连衣裙」二者之一;选了外套同样如此, +// 因为外套是外层、无法单独构成一套穿搭; +// 2. 下装:结果未选「连衣裙」且衣橱存在「下装」时,必须包含「下装」; +// 3. 鞋:衣橱存在「鞋」时必须包含「鞋」;「配饰」为可选点缀,不做要求; +// 4. 冲突:结果同时包含「连衣裙」与「下装」(二者互斥)。 +// +// 调用前需先执行 ValidateClothesIDs,保证 item.Type 为衣橱实际大类。 +func (r *RecommendLLMResponse) CompletenessIssues(clothes []mysqlmodel.Clothes) (missing []string, conflicts []string) { + wardrobe := wardrobeCategories(clothes) + picked := r.pickedCategories() + + hasDress := picked[categoryDress] + if hasInner := picked[categoryTop] || hasDress; !hasInner && + (picked[categoryCoat] || wardrobe[categoryTop] || wardrobe[categoryDress]) { + // 优先补「上装」,衣橱没有上装时补「连衣裙」;两者都没有则无法补齐,不做要求。 + switch { + case wardrobe[categoryTop]: + missing = append(missing, categoryTop) + case wardrobe[categoryDress]: + missing = append(missing, categoryDress) + } + } + if !hasDress && wardrobe[categoryBottom] && !picked[categoryBottom] { + missing = append(missing, categoryBottom) + } + if wardrobe[categoryShoes] && !picked[categoryShoes] { + missing = append(missing, categoryShoes) + } + if hasDress && picked[categoryBottom] { + conflicts = append(conflicts, categoryBottom) + } + return missing, conflicts +} + +// ApplyAutoComplete 确定性补齐缺失品类并剔除冲突项,返回新增与移除的品类。 +// 补齐顺序与 CompletenessIssues 的缺失顺序一致(内层 → 下装 → 鞋);候选单品按固定优先级排序,保证同输入同输出。 +func (r *RecommendLLMResponse) ApplyAutoComplete(clothes []mysqlmodel.Clothes) (added []string, removed []string) { + removed = append(removed, r.removeConflicts()...) + + pref := r.completionPref(clothes) + // 循环上限取品类总数(补齐最多 3 件),防御异常输入导致死循环。 + for round := 0; round < len(outfitCategories); round++ { + missing, conflicts := r.CompletenessIssues(clothes) + if len(conflicts) > 0 { + removed = append(removed, r.removeConflicts()...) + continue + } + if len(missing) == 0 { + break + } + item, ok := r.pickCompletionItem(clothes, missing[0], pref) + if !ok { + break // 衣橱中该品类没有可用单品:无法补齐,保留现状。 + } + r.Items = append(r.Items, *item) + added = append(added, missing[0]) + } + return added, removed +} + +// completionPreference 补齐偏好:已选单品的风格 / 主色 / 视觉冲击力众数(空串表示无偏好)。 +type completionPreference struct { + style string + color string + visualImpact string +} + +// completionPref 汇总已选单品对应衣物的风格/主色/视觉冲击力众数,用于挑选最协调的补齐单品。 +func (r *RecommendLLMResponse) completionPref(clothes []mysqlmodel.Clothes) completionPreference { + clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothes)) + for _, c := range clothes { + clothesByID[c.ID] = c + } + styles := make([]string, 0, len(r.Items)) + colors := make([]string, 0, len(r.Items)) + impacts := make([]string, 0, len(r.Items)) + for _, item := range r.Items { + c, ok := clothesByID[item.ClothesID] + if !ok { + continue + } + styles = append(styles, c.Style) + colors = append(colors, c.ColorMain) + impacts = append(impacts, c.VisualImpact) + } + return completionPreference{ + style: mostFrequent(styles), + color: mostFrequent(colors), + visualImpact: mostFrequent(impacts), + } +} + +// pickCompletionItem 为指定品类挑选补齐单品:排除已在结果中的衣物,按 +// 「同风格 → 主色一致 → 视觉冲击力一致 → ID 最新」的确定性优先级取第一件。 +func (r *RecommendLLMResponse) pickCompletionItem(clothes []mysqlmodel.Clothes, category string, pref completionPreference) (*RecommendLLMItem, bool) { + picked := make(map[int64]bool, len(r.Items)) + for _, item := range r.Items { + picked[item.ClothesID] = true + } + var best *mysqlmodel.Clothes + for i := range clothes { + c := &clothes[i] + if c.CategoryMain != category || picked[c.ID] { + continue + } + if best == nil || completionBetter(c, best, pref) { + best = c + } + } + if best == nil { + return nil, false + } + return &RecommendLLMItem{ + Type: best.CategoryMain, + ClothesID: best.ID, + ClothesUUID: best.ClothesUUID, + Reason: "系统按搭配完整性规则补齐(原推荐缺少" + category + ")", + }, true +} + +// completionBetter 报告 a 是否应优先于 b(确定性排序键,保证同输入同输出)。 +func completionBetter(a, b *mysqlmodel.Clothes, pref completionPreference) bool { + if hitA, hitB := prefMatch(a.Style, pref.style), prefMatch(b.Style, pref.style); hitA != hitB { + return hitA + } + if hitA, hitB := prefMatch(a.ColorMain, pref.color), prefMatch(b.ColorMain, pref.color); hitA != hitB { + return hitA + } + if hitA, hitB := prefMatch(a.VisualImpact, pref.visualImpact), prefMatch(b.VisualImpact, pref.visualImpact); hitA != hitB { + return hitA + } + return a.ID > b.ID // 同一优先级时取最近上传的一件,保证结果稳定可复现。 +} + +// prefMatch 判断候选值是否命中偏好值(偏好为空视为不命中,避免空值互相“匹配”造成排序漂移)。 +func prefMatch(value, preference string) bool { + return preference != "" && value == preference +} + +// removeConflicts 剔除与「连衣裙」互斥的「下装」,返回被移除的品类。 +func (r *RecommendLLMResponse) removeConflicts() (removed []string) { + picked := r.pickedCategories() + if !picked[categoryDress] || !picked[categoryBottom] { + return nil + } + kept := make([]RecommendLLMItem, 0, len(r.Items)) + for _, item := range r.Items { + if item.Type == categoryBottom { + removed = append(removed, item.Type) + continue + } + kept = append(kept, item) + } + r.Items = kept + return removed +} + +// pickedCategories 返回推荐结果覆盖的品类集合。 +func (r *RecommendLLMResponse) pickedCategories() map[string]bool { + picked := make(map[string]bool, len(r.Items)) + for _, item := range r.Items { + picked[item.Type] = true + } + return picked +} + +// wardrobeCategories 统计衣橱中实际存在的大类(仅 6 类有意义,其它取值不影响规则判定)。 +func wardrobeCategories(clothes []mysqlmodel.Clothes) map[string]bool { + wardrobe := make(map[string]bool, len(outfitCategories)) + for _, c := range clothes { + wardrobe[c.CategoryMain] = true + } + return wardrobe +} + +// mostFrequent 返回非空值中出现次数最多的值(并列时取先出现者);全为空时返回空串。 +func mostFrequent(values []string) string { + counts := make(map[string]int, len(values)) + best, bestCount := "", 0 + for _, v := range values { + if v == "" { + continue + } + counts[v]++ + if counts[v] > bestCount { + best, bestCount = v, counts[v] + } + } + return best +} + // FormatClothesInfo 将衣橱衣物列表转换为传给 LLM 的摘要格式。 // 使用「字段名:值」标注,补全穿搭相关字段,便于模型准确理解每个属性。 // 所有字符串字段经 sanitizePromptField 清洗,降低 prompt 注入风险。 diff --git a/pkg/tokenhub/outfit_recommend_test.go b/pkg/tokenhub/outfit_recommend_test.go new file mode 100644 index 0000000..d8cb93d --- /dev/null +++ b/pkg/tokenhub/outfit_recommend_test.go @@ -0,0 +1,266 @@ +package tokenhub + +import ( + "fmt" + "strings" + "testing" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" +) + +// clothesFixture 构造衣橱衣物测试数据(ID/UUID 由 id 派生,便于断言)。 +func clothesFixture(id int64, category, style, color, impact string) mysqlmodel.Clothes { + return mysqlmodel.Clothes{ + Model: mysqlmodel.Model{ID: id}, + ClothesUUID: fmt.Sprintf("uuid-%d", id), + CategoryMain: category, + Style: style, + ColorMain: color, + VisualImpact: impact, + } +} + +// itemFixture 构造推荐单品(Type 已按衣橱实际大类纠正,模拟 ValidateClothesIDs 之后的结果)。 +func itemFixture(category string, id int64) RecommendLLMItem { + return RecommendLLMItem{ + Type: category, + ClothesID: id, + ClothesUUID: fmt.Sprintf("uuid-%d", id), + Reason: "test", + } +} + +// TestCompletenessIssues 校验完整性判定:内层必选、外套不可孤立、下装/鞋按衣橱存在与否强制、配饰不强制、连衣裙与下装冲突。 +func TestCompletenessIssues(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{ + clothesFixture(1, "上装", "休闲", "黑", "3"), + clothesFixture(2, "下装", "休闲", "蓝", "2"), + clothesFixture(3, "外套", "休闲", "灰", "2"), + clothesFixture(4, "鞋", "休闲", "白", "1"), + clothesFixture(5, "连衣裙", "甜美", "粉", "3"), + clothesFixture(6, "配饰", "休闲", "金", "2"), + } + + for _, tc := range []struct { + name string + picked []RecommendLLMItem + wantMissing []string + wantConflict []string + }{ + { + name: "外套加下装缺上装与鞋", + picked: []RecommendLLMItem{itemFixture("外套", 3), itemFixture("下装", 2)}, + wantMissing: []string{"上装", "鞋"}, + }, + { + name: "上装下装鞋齐全", + picked: []RecommendLLMItem{itemFixture("上装", 1), itemFixture("下装", 2), itemFixture("鞋", 4)}, + }, + { + name: "连衣裙加鞋完整", + picked: []RecommendLLMItem{itemFixture("连衣裙", 5), itemFixture("鞋", 4)}, + }, + { + name: "连衣裙与下装冲突", + picked: []RecommendLLMItem{itemFixture("连衣裙", 5), itemFixture("下装", 2), itemFixture("鞋", 4)}, + wantConflict: []string{"下装"}, + }, + { + name: "缺下装", + picked: []RecommendLLMItem{itemFixture("上装", 1), itemFixture("鞋", 4)}, + wantMissing: []string{"下装"}, + }, + { + name: "配饰不强制但缺鞋仍要补", + picked: []RecommendLLMItem{itemFixture("上装", 1), itemFixture("下装", 2)}, + wantMissing: []string{"鞋"}, + }, + { + name: "只有配饰时按规则补齐主品类", + picked: []RecommendLLMItem{itemFixture("配饰", 6)}, + wantMissing: []string{"上装", "下装", "鞋"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + r := &RecommendLLMResponse{Items: tc.picked} + missing, conflicts := r.CompletenessIssues(wardrobe) + if got, want := strings.Join(missing, ","), strings.Join(tc.wantMissing, ","); got != want { + t.Fatalf("missing = %q, want %q", got, want) + } + if got, want := strings.Join(conflicts, ","), strings.Join(tc.wantConflict, ","); got != want { + t.Fatalf("conflicts = %q, want %q", got, want) + } + }) + } +} + +// TestCompletenessIssuesWardrobeWithoutInner 衣橱没有内层时不强制内层(避免要求补齐衣橱中不存在的品类)。 +func TestCompletenessIssuesWardrobeWithoutInner(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{ + clothesFixture(1, "外套", "休闲", "黑", "3"), + clothesFixture(2, "下装", "休闲", "黑", "2"), + } + r := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("外套", 1), itemFixture("下装", 2)}} + missing, conflicts := r.CompletenessIssues(wardrobe) + if len(missing) != 0 || len(conflicts) != 0 { + t.Fatalf("missing=%v conflicts=%v, want both empty", missing, conflicts) + } + + // 衣橱只有连衣裙、结果只选了外套:应补连衣裙(外套不可孤立)。 + dressOnly := []mysqlmodel.Clothes{clothesFixture(5, "连衣裙", "甜美", "粉", "3")} + coatOnly := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("外套", 9)}} + missing, _ = coatOnly.CompletenessIssues(dressOnly) + if strings.Join(missing, ",") != "连衣裙" { + t.Fatalf("missing = %v, want [连衣裙]", missing) + } +} + +// TestNormalizeCategories 同一品类只保留首次出现的单品。 +func TestNormalizeCategories(t *testing.T) { + r := &RecommendLLMResponse{Items: []RecommendLLMItem{ + itemFixture("上装", 1), itemFixture("上装", 2), itemFixture("下装", 3), + }} + removed := r.NormalizeCategories() + if len(r.Items) != 2 || r.Items[0].ClothesID != 1 || r.Items[1].ClothesID != 3 { + t.Fatalf("items = %+v", r.Items) + } + if strings.Join(removed, ",") != "上装" { + t.Fatalf("removed = %v, want [上装]", removed) + } +} + +// TestApplyAutoComplete 自动补齐:用户反馈的「只有外套+下装」场景补齐上装与鞋,且补齐单品按同风格/同主色优先级确定性挑选。 +func TestApplyAutoComplete(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{ + clothesFixture(1, "上装", "休闲", "黑", "3"), // 与已选下装同风格同主色 -> 应被选中 + clothesFixture(2, "上装", "商务", "白", "4"), + clothesFixture(3, "下装", "休闲", "黑", "2"), + clothesFixture(4, "鞋", "商务", "白", "1"), + } + r := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("外套", 9), itemFixture("下装", 3)}} + added, removed := r.ApplyAutoComplete(wardrobe) + if strings.Join(added, ",") != "上装,鞋" { + t.Fatalf("added = %v, want [上装 鞋]", added) + } + if len(removed) != 0 { + t.Fatalf("removed = %v, want empty", removed) + } + + var top *RecommendLLMItem + for i := range r.Items { + if r.Items[i].Type == "上装" { + top = &r.Items[i] + } + } + if top == nil || top.ClothesID != 1 || top.ClothesUUID != "uuid-1" { + t.Fatalf("补齐的上装应取同风格同主色的 ID1,实际 = %+v", top) + } + if !strings.Contains(top.Reason, "系统按搭配完整性规则补齐") { + t.Fatalf("补齐理由未标注系统补齐: %q", top.Reason) + } + // 补齐后必须完整(补齐再校验)。 + if missing, conflicts := r.CompletenessIssues(wardrobe); len(missing) != 0 || len(conflicts) != 0 { + t.Fatalf("still incomplete: missing=%v conflicts=%v", missing, conflicts) + } +} + +// TestApplyAutoCompleteRemovesConflict 选连衣裙时剔除与之冲突的下装,并补上缺的鞋。 +func TestApplyAutoCompleteRemovesConflict(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{ + clothesFixture(5, "连衣裙", "甜美", "粉", "3"), + clothesFixture(3, "下装", "休闲", "黑", "2"), + clothesFixture(4, "鞋", "甜美", "白", "1"), + } + r := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("连衣裙", 5), itemFixture("下装", 3)}} + added, removed := r.ApplyAutoComplete(wardrobe) + if strings.Join(removed, ",") != "下装" { + t.Fatalf("removed = %v, want [下装]", removed) + } + if strings.Join(added, ",") != "鞋" { + t.Fatalf("added = %v, want [鞋]", added) + } + for _, item := range r.Items { + if item.Type == "下装" { + t.Fatalf("下装 应被剔除: %+v", r.Items) + } + } +} + +// TestApplyAutoCompleteDeterministic 同输入必须同输出(便于线上排查与回归)。 +func TestApplyAutoCompleteDeterministic(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{ + clothesFixture(1, "上装", "休闲", "黑", "3"), + clothesFixture(2, "上装", "休闲", "白", "3"), + clothesFixture(3, "下装", "休闲", "黑", "2"), + clothesFixture(4, "鞋", "休闲", "黑", "1"), + clothesFixture(5, "鞋", "商务", "白", "1"), + } + first := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("外套", 9), itemFixture("下装", 3)}} + second := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("外套", 9), itemFixture("下装", 3)}} + first.ApplyAutoComplete(wardrobe) + second.ApplyAutoComplete(wardrobe) + + if len(first.Items) != len(second.Items) { + t.Fatalf("length mismatch: %d vs %d", len(first.Items), len(second.Items)) + } + for i := range first.Items { + if first.Items[i].ClothesID != second.Items[i].ClothesID { + t.Fatalf("run %d differs: %+v vs %+v", i, first.Items, second.Items) + } + } +} + +// TestApplyAutoCompleteSkipsUnavailableCategory 衣橱没有该品类时无法补齐(不做无意义的失败)。 +func TestApplyAutoCompleteSkipsUnavailableCategory(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{clothesFixture(3, "下装", "休闲", "黑", "2")} + r := &RecommendLLMResponse{Items: []RecommendLLMItem{itemFixture("下装", 3)}} + added, removed := r.ApplyAutoComplete(wardrobe) + if len(added) != 0 || len(removed) != 0 { + t.Fatalf("added=%v removed=%v, want no change", added, removed) + } + if len(r.Items) != 1 { + t.Fatalf("items = %+v, want unchanged", r.Items) + } +} + +// TestBuildCorrectionHint 纠偏提示:无问题返回空串;有问题时包含缺失/冲突品类。 +func TestBuildCorrectionHint(t *testing.T) { + if got := BuildCorrectionHint(nil, nil); got != "" { + t.Fatalf("empty issues should return empty hint, got %q", got) + } + got := BuildCorrectionHint([]string{"上装", "鞋"}, []string{"下装"}) + for _, want := range []string{"必须补充", "上装", "鞋", "必须移除", "下装"} { + if !strings.Contains(got, want) { + t.Fatalf("hint missing %q: %s", want, got) + } + } +} + +// TestBuildRecommendPromptCompleteness 提示词包含完整性硬规则与去重约束;纠偏段仅在重试时出现。 +func TestBuildRecommendPromptCompleteness(t *testing.T) { + wardrobe := []mysqlmodel.Clothes{clothesFixture(1, "上装", "休闲", "黑", "3")} + fortune := &FortuneItem{Overall: 4, Love: 3, Work: 2, Money: 5, Description: "状态不错"} + + first := buildRecommendPrompt("白羊座", fortune, wardrobe, "", "") + for _, want := range []string{ + "完整性要求", + "必须包含「上装」或「连衣裙」", + "外套是外层,不可孤立出现", + "二者互斥", + "每个存在的品类最多推荐 1 件", + } { + if !strings.Contains(first, want) { + t.Fatalf("prompt missing %q", want) + } + } + if strings.Contains(first, "上次输出问题") { + t.Fatal("首次调用不应包含纠偏段") + } + + retry := buildRecommendPrompt("白羊座", fortune, wardrobe, "通勤", BuildCorrectionHint([]string{"上装"}, nil)) + for _, want := range []string{"上次输出问题", "必须补充", "上装", "用户指定场景", "通勤"} { + if !strings.Contains(retry, want) { + t.Fatalf("retry prompt missing %q", want) + } + } +} diff --git a/接口文档.md b/接口文档.md index 3414194..91fe375 100644 --- a/接口文档.md +++ b/接口文档.md @@ -1125,12 +1125,13 @@ Authorization: Bearer 2. **提交时先扣 1 个 AI 额度**(`quota_type=2`);额度不足返回 **`90401` AI 推荐次数已达上限**(注意不是 `80000`)。 3. 接口**秒级返回**(实测 ~30ms),不等大模型。 4. 生成在后台执行,输入为:当前用户**处理成功(状态 4)**的全部衣物 + 生日推算的星座 + 今日运势(星座/运势缺失会降级,不影响生成)。 -5. 失败时**额度自动回退**,失败原因写入任务状态(`error_code` / `error_msg`),详见下方状态接口: +5. **搭配完整性保障**(按衣橱实际存在的品类判定):结果必须包含「上装」或「连衣裙」二者之一(**外套是外层,不可孤立出现**);未选「连衣裙」且衣橱有「下装」时必须含「下装」;衣橱有「鞋」时必须含「鞋」;「配饰」为可选;「连衣裙」与「下装」互斥。不满足时后端会**带纠偏提示自动重试一次模型**,仍不满足则由服务端从衣橱**确定性补齐**缺失品类(补齐单品的 `reason` 会标注"系统按搭配完整性规则补齐")。因此正常结果不会出现"只有外套+下装"这类穿不出去的搭配;同一品类最多 1 件。 +6. 失败时**额度自动回退**,失败原因写入任务状态(`error_code` / `error_msg`),详见下方状态接口: - `90400` + `衣橱暂无可用衣物` - `90400` + `大模型调用失败` - - `90400` + `未生成有效搭配` + - `90400` + `未生成有效搭配`(两次模型调用均未返回衣橱内的有效单品) - `90400` + `生成任务异常终止`(超过 3 分钟未完成) -6. ⚠️ **生成成功但未调用 save,不退还 AI 额度**(额度在提交时已消耗)。 +7. ⚠️ **生成成功但未调用 save,不退还 AI 额度**(额度在提交时已消耗)。 **请求**(json)