Set up project files and add .gitignore to exclude local build/runtime artifacts. Made-with: Cursor
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)
|
||
}
|