287 lines
7.7 KiB
Go
287 lines
7.7 KiB
Go
|
|
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)
|
|||
|
|
}
|