feat(ai-recommend): 保障搭配完整性并支持自动补齐

This commit is contained in:
2026-09-16 17:43:20 +08:00
parent b94dd6dffb
commit cf9dff3add
7 changed files with 829 additions and 25 deletions
+270 -13
View File
@@ -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 注入风险。