package gorse import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "time" gorseio "github.com/gorse-io/gorse-go" ) // FeedbackType 反馈类型 type FeedbackType string const ( FeedbackTypeLike FeedbackType = "like" // 点赞 FeedbackTypeStar FeedbackType = "star" // 收藏 FeedbackTypeComment FeedbackType = "comment" // 评论 FeedbackTypeRead FeedbackType = "read" // 浏览 ) // Score 非个性化推荐返回的评分项 type Score struct { Id string `json:"Id"` Score float64 `json:"Score"` } // Client Gorse客户端接口 type Client interface { // InsertFeedback 插入用户反馈 InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error // DeleteFeedback 删除用户反馈 DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error // GetRecommend 获取个性化推荐列表 GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) // GetNonPersonalized 获取非个性化推荐(通过名称) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) // UpsertItem 插入或更新物品(无embedding) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error // UpsertItemWithEmbedding 插入或更新物品(带embedding) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error // DeleteItem 删除物品 DeleteItem(ctx context.Context, itemID string) error // UpsertUser 插入或更新用户 UpsertUser(ctx context.Context, userID string, labels map[string]any) error // IsEnabled 检查是否启用 IsEnabled() bool } // client Gorse客户端实现 type client struct { config Config gorse *gorseio.GorseClient httpClient *http.Client } // NewClient 创建新的Gorse客户端 func NewClient(cfg Config) Client { if !cfg.Enabled { return &noopClient{} } gorse := gorseio.NewGorseClient(cfg.Address, cfg.APIKey) return &client{ config: cfg, gorse: gorse, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // IsEnabled 检查是否启用 func (c *client) IsEnabled() bool { return c.config.Enabled } // InsertFeedback 插入用户反馈 func (c *client) InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { if !c.config.Enabled { return nil } _, err := c.gorse.InsertFeedback(ctx, []gorseio.Feedback{ { FeedbackType: string(feedbackType), UserId: userID, ItemId: itemID, Timestamp: time.Now().UTC().Truncate(time.Second), }, }) return err } // DeleteFeedback 删除用户反馈 func (c *client) DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { if !c.config.Enabled { return nil } _, err := c.gorse.DeleteFeedback(ctx, string(feedbackType), userID, itemID) return err } // GetRecommend 获取个性化推荐列表 func (c *client) GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) { if !c.config.Enabled { return nil, nil } result, err := c.gorse.GetRecommend(ctx, userID, "", n, offset) if err != nil { return nil, err } return result, nil } // GetNonPersonalized 获取非个性化推荐 // name: 推荐器名称,如 "most_liked_weekly" // n: 返回数量 // offset: 偏移量 // userID: 可选,用于排除用户已读物品 func (c *client) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) { if !c.config.Enabled { return nil, nil } // 构建URL url := fmt.Sprintf("%s/api/non-personalized/%s?n=%d&offset=%d", c.config.Address, name, n, offset) if userID != "" { url += fmt.Sprintf("&user-id=%s", userID) } // 创建请求 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // 设置API Key if c.config.APIKey != "" { req.Header.Set("X-API-Key", c.config.APIKey) } // 发送请求 resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("gorse api error: status=%d, body=%s", resp.StatusCode, string(body)) } // 解析响应 var scores []Score if err := json.Unmarshal(body, &scores); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } // 提取ID ids := make([]string, len(scores)) for i, score := range scores { ids[i] = score.Id } return ids, nil } // UpsertItem 插入或更新物品 func (c *client) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error { if !c.config.Enabled { return nil } _, err := c.gorse.InsertItem(ctx, gorseio.Item{ ItemId: itemID, IsHidden: false, Categories: categories, Comment: comment, Timestamp: time.Now().UTC().Truncate(time.Second), }) return err } // UpsertItemWithEmbedding 插入或更新物品(带embedding) func (c *client) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error { if !c.config.Enabled { return nil } // 生成embedding var embedding []float64 if textToEmbed != "" { var err error embedding, err = GetEmbedding(textToEmbed) if err != nil { log.Printf("[WARN] Failed to get embedding for item %s: %v, using zero vector", itemID, err) embedding = make([]float64, 1024) } } else { embedding = make([]float64, 1024) } _, err := c.gorse.InsertItem(ctx, gorseio.Item{ ItemId: itemID, IsHidden: false, Categories: categories, Comment: comment, Timestamp: time.Now().UTC().Truncate(time.Second), Labels: map[string]any{ "embedding": embedding, }, }) return err } // DeleteItem 删除物品 func (c *client) DeleteItem(ctx context.Context, itemID string) error { if !c.config.Enabled { return nil } _, err := c.gorse.DeleteItem(ctx, itemID) return err } // UpsertUser 插入或更新用户 func (c *client) UpsertUser(ctx context.Context, userID string, labels map[string]any) error { if !c.config.Enabled { return nil } _, err := c.gorse.InsertUser(ctx, gorseio.User{ UserId: userID, Labels: labels, }) return err } // noopClient 空操作客户端(用于未启用推荐功能时) type noopClient struct{} func (c *noopClient) IsEnabled() bool { return false } func (c *noopClient) InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { return nil } func (c *noopClient) DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { return nil } func (c *noopClient) GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) { return nil, nil } func (c *noopClient) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) { return nil, nil } func (c *noopClient) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error { return nil } func (c *noopClient) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error { return nil } func (c *noopClient) DeleteItem(ctx context.Context, itemID string) error { return nil } func (c *noopClient) UpsertUser(ctx context.Context, userID string, labels map[string]any) error { return nil } // 确保实现了接口 var _ Client = (*client)(nil) var _ Client = (*noopClient)(nil) // log 用于内部日志 func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) }