feat(ai-recommend): 保障搭配完整性并支持自动补齐
This commit is contained in:
@@ -28,6 +28,10 @@ const (
|
||||
|
||||
// aiRecommendCleanupTimeout 任务收尾(退额度 / 写流水 / 释放活跃位)的超时。
|
||||
aiRecommendCleanupTimeout = 15 * time.Second
|
||||
|
||||
// aiRecommendRetryMinBudget 结果不完整时发起模型重试所需的最小剩余预算。
|
||||
// 大模型单次调用上限 120s,预算不足时跳过重试、直接走服务端补齐,避免重试把任务拖到整体超时失败。
|
||||
aiRecommendRetryMinBudget = 90 * time.Second
|
||||
)
|
||||
|
||||
// AIRecommendService AI 穿搭推荐业务服务。
|
||||
@@ -291,19 +295,24 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID
|
||||
// 3. 调用大模型生成推荐。
|
||||
requestTime := nowUnix()
|
||||
client := tokenhub.NewClient()
|
||||
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene)
|
||||
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene, "")
|
||||
if gerr != nil {
|
||||
slog.ErrorContext(ctx, "generate outfit recommendation failed", "user_id", userID, "task_id", taskID, "error", gerr)
|
||||
return nil, errcode.AIRecommendFailed.WithDetails("大模型调用失败")
|
||||
}
|
||||
result.ValidateClothesIDs(clothes)
|
||||
result.NormalizeCategories()
|
||||
|
||||
// 4. 搭配完整性保障:缺关键品类时带纠偏提示重试一次模型,仍不满足则由服务端从衣橱补齐,
|
||||
// 避免出现「只有外套 + 下装」这类穿不出去的搭配。
|
||||
result = s.completeOutfit(ctx, client, taskID, userID, zodiac, fortune, clothes, request.Scene, result)
|
||||
if len(result.Items) == 0 {
|
||||
slog.WarnContext(ctx, "ai recommend generate: no valid items after validation",
|
||||
"user_id", userID, "task_id", taskID)
|
||||
return nil, errcode.AIRecommendFailed.WithDetails("未生成有效搭配")
|
||||
}
|
||||
|
||||
// 4. 落库为待用户保存的 AI 穿搭。
|
||||
// 5. 落库为待用户保存的 AI 穿搭。
|
||||
scene := result.Scene
|
||||
if scene == "" {
|
||||
scene = request.Scene
|
||||
@@ -335,7 +344,7 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID
|
||||
return nil, errcode.OutfitCreateFailed
|
||||
}
|
||||
|
||||
// 5. 写成功流水(best-effort)。
|
||||
// 6. 写成功流水(best-effort)。
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
|
||||
RequestID: genUUID(),
|
||||
@@ -349,7 +358,7 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID
|
||||
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
|
||||
}
|
||||
|
||||
// 6. 组装响应(衣物 UUID 来自校正后的 items)。
|
||||
// 7. 组装响应(衣物 UUID 来自校正后的 items)。
|
||||
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
|
||||
for _, it := range result.Items {
|
||||
outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID})
|
||||
@@ -366,6 +375,86 @@ func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID
|
||||
}, nil
|
||||
}
|
||||
|
||||
// completeOutfit 保障搭配完整性:结果缺关键品类(或首次没有任何有效单品)时,
|
||||
// 带纠偏提示重试一次模型调用;仍不满足则由服务端按确定性规则从衣橱补齐缺失品类并剔除冲突项。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 只重试一次,避免放大时延与模型成本;
|
||||
// - 重试前检查任务剩余预算(客户端单次调用上限 120s),预算不足直接走服务端补齐;
|
||||
// - 重试失败或重试结果反而为空时,保留首次结果继续补齐,不让用户因为"纠偏"而拿不到搭配。
|
||||
func (s *AIRecommendService) completeOutfit(ctx context.Context, client *tokenhub.Client, taskID string, userID int64,
|
||||
zodiac string, fortune *tokenhub.FortuneItem, clothes []mysqlmodel.Clothes, scene string,
|
||||
result *tokenhub.RecommendLLMResponse) *tokenhub.RecommendLLMResponse {
|
||||
|
||||
missing, conflicts := result.CompletenessIssues(clothes)
|
||||
if len(result.Items) > 0 && len(missing) == 0 && len(conflicts) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
if !hasRetryBudget(ctx) {
|
||||
slog.WarnContext(ctx, "ai recommend: skip model retry, remaining budget insufficient",
|
||||
"user_id", userID, "task_id", taskID, "items", len(result.Items),
|
||||
"missing", missing, "conflicts", conflicts)
|
||||
return s.autoCompleteOutfit(ctx, taskID, userID, result, clothes)
|
||||
}
|
||||
|
||||
hint := tokenhub.BuildCorrectionHint(missing, conflicts)
|
||||
if len(result.Items) == 0 {
|
||||
hint = tokenhub.CorrectionHintStrictSelection
|
||||
}
|
||||
slog.InfoContext(ctx, "ai recommend: outfit incomplete, retry with correction hint",
|
||||
"user_id", userID, "task_id", taskID, "items", len(result.Items),
|
||||
"missing", missing, "conflicts", conflicts)
|
||||
|
||||
retry, rerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, scene, hint)
|
||||
if rerr != nil {
|
||||
slog.WarnContext(ctx, "ai recommend: model retry failed, fallback to auto complete",
|
||||
"user_id", userID, "task_id", taskID, "error", rerr)
|
||||
return s.autoCompleteOutfit(ctx, taskID, userID, result, clothes)
|
||||
}
|
||||
retry.ValidateClothesIDs(clothes)
|
||||
retry.NormalizeCategories()
|
||||
if len(retry.Items) == 0 {
|
||||
slog.WarnContext(ctx, "ai recommend: model retry returned no valid item, keep first result",
|
||||
"user_id", userID, "task_id", taskID)
|
||||
return s.autoCompleteOutfit(ctx, taskID, userID, result, clothes)
|
||||
}
|
||||
|
||||
retryMissing, retryConflicts := retry.CompletenessIssues(clothes)
|
||||
if len(retryMissing) == 0 && len(retryConflicts) == 0 {
|
||||
slog.InfoContext(ctx, "ai recommend: outfit complete after retry",
|
||||
"user_id", userID, "task_id", taskID, "items", len(retry.Items))
|
||||
return retry
|
||||
}
|
||||
slog.WarnContext(ctx, "ai recommend: still incomplete after retry, auto complete",
|
||||
"user_id", userID, "task_id", taskID, "missing", retryMissing, "conflicts", retryConflicts)
|
||||
return s.autoCompleteOutfit(ctx, taskID, userID, retry, clothes)
|
||||
}
|
||||
|
||||
// autoCompleteOutfit 服务端补齐:从衣橱确定性挑选缺失品类单品并剔除冲突项(如「连衣裙 + 下装」)。
|
||||
// 仅在已有推荐结果(items 非空)时补齐,避免凭空构造整套搭配。
|
||||
func (s *AIRecommendService) autoCompleteOutfit(ctx context.Context, taskID string, userID int64,
|
||||
result *tokenhub.RecommendLLMResponse, clothes []mysqlmodel.Clothes) *tokenhub.RecommendLLMResponse {
|
||||
if len(result.Items) == 0 {
|
||||
return result // 无基准结果:交由调用方按「未生成有效搭配」处理(额度退还)。
|
||||
}
|
||||
added, removed := result.ApplyAutoComplete(clothes)
|
||||
if len(added) > 0 || len(removed) > 0 {
|
||||
slog.WarnContext(ctx, "ai recommend: outfit auto completed by server",
|
||||
"user_id", userID, "task_id", taskID, "added_categories", added, "removed_categories", removed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// hasRetryBudget 判断任务是否还有足够预算发起一次模型重试;无 deadline 时视为预算充足。
|
||||
func hasRetryBudget(ctx context.Context) bool {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return time.Until(deadline) >= aiRecommendRetryMinBudget
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
// processedClothes 获取用户已成功处理、可用于推荐的衣物。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHasRetryBudget 模型重试预算判定:
|
||||
// 无 deadline 视为充足;剩余不足 aiRecommendRetryMinBudget 时不允许再发起一次模型调用(改为服务端补齐)。
|
||||
func TestHasRetryBudget(t *testing.T) {
|
||||
if !hasRetryBudget(context.Background()) {
|
||||
t.Fatal("no deadline should be treated as budget available")
|
||||
}
|
||||
|
||||
short, cancelShort := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelShort()
|
||||
if hasRetryBudget(short) {
|
||||
t.Fatalf("60s remaining should be insufficient (min budget %s)", aiRecommendRetryMinBudget)
|
||||
}
|
||||
|
||||
enough, cancelEnough := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancelEnough()
|
||||
if !hasRetryBudget(enough) {
|
||||
t.Fatal("5m remaining should allow one model retry")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user