Files
zt-closet-backend/pkg/tokenhub/outfit_recommend.go
T

528 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}