271 lines
11 KiB
Go
271 lines
11 KiB
Go
package tokenhub
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
|
)
|
|
|
|
// outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。
|
|
var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"}
|
|
|
|
// 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 时传入),为空表示由模型自行判断。
|
|
func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene string) (*RecommendLLMResponse, error) {
|
|
messages := []Message{
|
|
{Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes, scene)},
|
|
}
|
|
|
|
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 字段回填;
|
|
// 为空时整段不出现,保持「由模型自行判断场景」的原行为。
|
|
func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene string) string {
|
|
clothesText := FormatClothesInfo(clothes)
|
|
|
|
sceneSection := ""
|
|
if s := sanitizePromptField(scene); s != "" {
|
|
sceneSection = fmt.Sprintf("\n## 用户指定场景\n用户希望这套搭配用于「%s」,请优先围绕该场景挑选单品,并在 scene 字段回填该场景。\n", s)
|
|
}
|
|
|
|
return fmt.Sprintf(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。
|
|
|
|
## 用户信息
|
|
- 星座:%s
|
|
- 今日综合运势:%d 星(满分 5 星)
|
|
- 今日爱情运势:%d 星
|
|
- 今日工作运势:%d 星
|
|
- 今日财富运势:%d 星
|
|
- 今日运势描述:%s
|
|
|
|
## 用户衣橱
|
|
%s
|
|
%s
|
|
## 搭配规则
|
|
1. 所有推荐单品必须严格选自用户衣橱,clothes_id 与 clothes_uuid 必须与衣橱中存在的衣物一一对应,不得虚构
|
|
2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰:
|
|
- 每个存在的品类最多推荐 1 件最合适的单品
|
|
- 衣橱中没有的品类,不在结果中输出,无需强制凑满 6 件
|
|
- 连衣裙为「一件式」单品,若选择连衣裙则通常不再搭配下装,可搭配外套、鞋、配饰
|
|
- 鞋为必备品类(若衣橱中存在),配饰为可选点缀
|
|
3. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配
|
|
4. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐
|
|
5. 保证所选单品之间颜色、风格协调统一
|
|
6. 为每件推荐单品撰写一句简洁的推荐理由
|
|
7. 为整套穿搭拟定一个贴合风格与运势的好听标题
|
|
|
|
## 输出要求
|
|
必须严格按以下 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|