feat(ai-recommend): 保障搭配完整性并支持自动补齐
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user