package tokenhub import ( "context" "encoding/json" "fmt" "strings" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" ) // outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。 var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"} // 完整性规则涉及的品类常量(取值必须与 outfitCategories 一致)。 const ( categoryTop = "上装" categoryBottom = "下装" categoryDress = "连衣裙" categoryCoat = "外套" categoryShoes = "鞋" ) // RecommendLLMItem 大模型返回的单品推荐。 type RecommendLLMItem struct { Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰 ClothesID int64 `json:"clothes_id"` // 衣橱衣物 ID ClothesUUID string `json:"clothes_uuid"` // 衣橱衣物 UUID Reason string `json:"reason"` // 选择理由 } // RecommendLLMResponse 大模型返回的穿搭推荐。 type RecommendLLMResponse struct { OutfitName string `json:"outfit_name"` Description string `json:"description"` Style string `json:"style"` // 穿搭风格 Scene string `json:"scene"` // 适合场景 Items []RecommendLLMItem `json:"items"` } // GenerateOutfitRecommendation 调用混元生成今日穿搭推荐。 // scene 为用户自定义场景(前端 scene_type=2 时传入),为空表示由模型自行判断。 // 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, retryHint)}, } responseFormat := &ResponseFormat{ Type: "json_schema", JSONSchema: &JSONSchema{ Name: "outfit_recommendation", Schema: buildRecommendSchema(), }, } data, err := c.ChatCompletion(ctx, messages, responseFormat) if err != nil { return nil, fmt.Errorf("call hunyuan for outfit recommend: %w", err) } var cr chatResponse if err := json.Unmarshal(data, &cr); err != nil { return nil, fmt.Errorf("unmarshal chat response: %w", err) } if len(cr.Choices) == 0 { return nil, fmt.Errorf("no choices in response") } var result RecommendLLMResponse content := sanitizeJSONContent(cr.Choices[0].Message.Content) if err := json.Unmarshal([]byte(content), &result); err != nil { return nil, fmt.Errorf("unmarshal outfit recommend json: %w, raw=%s", err, content) } return &result, nil } // buildRecommendPrompt 构造穿搭推荐 prompt。 // scene 为用户自定义场景(已做注入清洗):非空时作为硬约束写入 prompt,并指示模型在 scene 字段回填; // 为空时整段不出现,保持「由模型自行判断场景」的原行为。 // retryHint 为本包生成的纠偏提示(不含用户输入);为空时整段不出现。 func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene, retryHint string) string { clothesText := FormatClothesInfo(clothes) sceneSection := "" if s := sanitizePromptField(scene); s != "" { 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(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。 ## 用户信息 - 星座:%s - 今日综合运势:%d 星(满分 5 星) - 今日爱情运势:%d 星 - 今日工作运势:%d 星 - 今日财富运势:%d 星 - 今日运势描述:%s ## 用户衣橱 %s %s ## 搭配规则 1. 所有推荐单品必须严格选自用户衣橱,clothes_id 与 clothes_uuid 必须与衣橱中存在的衣物一一对应,不得虚构 2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰: - 每个存在的品类最多推荐 1 件最合适的单品 - 衣橱中没有的品类,不在结果中输出,无需强制凑满 6 件 3. 完整性要求(判定依据为衣橱实际存在的品类,必须满足,否则视为无效输出): - 必须包含「上装」或「连衣裙」二者之一(衣橱存在其中任一时)——不能只推荐外套或只推荐下装 - 选择「外套」时必须同时搭配「上装」或「连衣裙」(外套是外层,不可孤立出现) - 选择「连衣裙」时不再搭配「下装」(二者互斥) - 未选择「连衣裙」且衣橱存在「下装」时,必须包含「下装」 - 衣橱存在「鞋」时必须包含「鞋」;「配饰」为可选点缀 4. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配 5. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐 6. 保证所选单品之间颜色、风格协调统一 7. 为每件推荐单品撰写一句简洁的推荐理由 8. 为整套穿搭拟定一个贴合风格与运势的好听标题 %s ## 输出要求 必须严格按以下 JSON 结构输出,不要输出任何多余文字、解释或代码围栏: { "outfit_name": "穿搭标题", "description": "结合今日运势的整体搭配说明", "style": "穿搭风格,如:简约/复古/街头/甜美等", "scene": "适合场景,如:通勤/约会/休闲/运动等", "items": [ {"type": "上装", "clothes_id": 1, "clothes_uuid": "衣橱中该衣物对应的UUID", "reason": "单品推荐理由"} ] } 补充约束: - type 字段取值仅限「上装」「下装」「连衣裙」「外套」「鞋」「配饰」,与衣橱实际品类一一对应 - style 表示穿搭风格(如简约/复古/街头/甜美),scene 表示适合场景(如通勤/约会/休闲/运动),两者含义不同,必须分别输出 - items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组;且必须满足上述「完整性要求」 - clothes_id 必须为整数,且必须是衣橱中真实存在的 ID - clothes_uuid 必须从「用户衣橱」信息中精确复制,且与 clothes_id 一一对应,严禁编造或改动 UUID`, zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText, sceneSection, correctionSection) } // buildRecommendSchema 构造 JSON Schema 要求大模型结构化输出。 func buildRecommendSchema() map[string]interface{} { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "outfit_name": map[string]interface{}{ "type": "string", "description": "穿搭标题", }, "description": map[string]interface{}{ "type": "string", "description": "穿搭整体描述,结合运势说明搭配理由", }, "style": map[string]interface{}{ "type": "string", "description": "穿搭风格,如:简约、复古、街头、甜美等", }, "scene": map[string]interface{}{ "type": "string", "description": "适合场景,如:通勤、约会、休闲、运动等", }, "items": map[string]interface{}{ "type": "array", "description": "推荐的单品列表,每个存在的品类最多一件,不存在的品类不输出", "items": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "type": map[string]interface{}{ "type": "string", "description": "品类,必须是以下之一:上装、下装、连衣裙、外套、鞋、配饰", "enum": outfitCategories, }, "clothes_id": map[string]interface{}{ "type": "integer", "description": "衣橱中衣物的 ID", }, "clothes_uuid": map[string]interface{}{ "type": "string", "description": "衣橱中衣物的 UUID,必须与 clothes_id 对应", }, "reason": map[string]interface{}{ "type": "string", "description": "推荐理由,一句话说明为什么选这件", }, }, "required": []string{"type", "clothes_id", "clothes_uuid", "reason"}, }, }, }, "required": []string{"outfit_name", "description", "style", "scene", "items"}, } } // ValidateClothesIDs 校验并纠偏大模型返回的推荐结果: // 1. 过滤 clothes_id 不在用户衣橱中的项; // 2. 将 type 规范化为衣物实际的大类(CategoryMain); // 3. 以衣橱实际数据回填正确的 ClothesUUID(防止模型抄错 UUID); // 4. 对重复的 clothes_id 去重(保留首次出现)。 func (r *RecommendLLMResponse) ValidateClothesIDs(clothes []mysqlmodel.Clothes) { clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothes)) for _, c := range clothes { clothesByID[c.ID] = c } filtered := make([]RecommendLLMItem, 0, len(r.Items)) seen := make(map[int64]struct{}, len(r.Items)) for _, item := range r.Items { c, ok := clothesByID[item.ClothesID] if !ok { continue } if _, dup := seen[item.ClothesID]; dup { continue } // 以衣橱中实际数据为准,纠偏模型可能返回的 type 与 UUID。 item.Type = c.CategoryMain item.ClothesUUID = c.ClothesUUID seen[item.ClothesID] = struct{}{} filtered = append(filtered, item) } r.Items = filtered } // GetClothesIDs 获取推荐中所有不重复的 clothes ID。 func (r *RecommendLLMResponse) GetClothesIDs() []int64 { ids := make([]int64, 0, len(r.Items)) seen := make(map[int64]struct{}, len(r.Items)) for _, item := range r.Items { if _, ok := seen[item.ClothesID]; ok { continue } seen[item.ClothesID] = struct{}{} ids = append(ids, item.ClothesID) } 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 注入风险。 func FormatClothesInfo(clothesList []mysqlmodel.Clothes) string { var sb strings.Builder for _, c := range clothesList { sb.WriteString(fmt.Sprintf( "- ID:%d | UUID:%s | 大类:%s | 小类:%s | 名称:%s | 主色:%s | 副色:%s | 风格:%s | 版型:%s | 材质:%s | 花纹:%s | 厚度:%s | 袖型:%s | 衣长:%s | 领型:%s | 正式度:%s | 场合:%s | 视觉冲击力:%s\n", c.ID, sanitizePromptField(c.ClothesUUID), sanitizePromptField(c.CategoryMain), sanitizePromptField(c.CategorySub), sanitizePromptField(c.Name), sanitizePromptField(c.ColorMain), sanitizePromptField(c.ColorSub), sanitizePromptField(c.Style), sanitizePromptField(c.Version), sanitizePromptField(c.Material), sanitizePromptField(c.Pattern), sanitizePromptField(c.Thickness), sanitizePromptField(c.SleeveType), sanitizePromptField(c.ClothesType), sanitizePromptField(c.NecklineType), sanitizePromptField(c.Formality), sanitizePromptField(c.Occasions), sanitizePromptField(c.VisualImpact))) } return sb.String() } // sanitizePromptField 清洗拼入 prompt 的用户可控字段: // 截断长度、移除换行与控制字符、压缩连续空白,降低 prompt 注入风险。 func sanitizePromptField(s string) string { const maxLen = 200 if s == "" { return "" } var b strings.Builder for _, r := range s { switch { case r == '\n' || r == '\r' || r == '\t': b.WriteRune(' ') // 换行/制表符压缩为空格,防止注入换行指令。 case r < 0x20 || r == 0x7F: continue // 丢弃其它控制字符。 default: b.WriteRune(r) } } // 压缩连续空白。 out := strings.Join(strings.Fields(b.String()), " ") runes := []rune(out) if len(runes) > maxLen { out = string(runes[:maxLen]) } return out }