Initial backend repository commit.
Set up project files and add .gitignore to exclude local build/runtime artifacts. Made-with: Cursor
This commit is contained in:
286
internal/pkg/gorse/client.go
Normal file
286
internal/pkg/gorse/client.go
Normal file
@@ -0,0 +1,286 @@
|
||||
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)
|
||||
}
|
||||
23
internal/pkg/gorse/config.go
Normal file
23
internal/pkg/gorse/config.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package gorse
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/config"
|
||||
)
|
||||
|
||||
// Config Gorse客户端配置(从config.GorseConfig转换)
|
||||
type Config struct {
|
||||
Address string
|
||||
APIKey string
|
||||
Enabled bool
|
||||
Dashboard string
|
||||
}
|
||||
|
||||
// ConfigFromAppConfig 从应用配置创建Gorse配置
|
||||
func ConfigFromAppConfig(cfg *config.GorseConfig) Config {
|
||||
return Config{
|
||||
Address: cfg.Address,
|
||||
APIKey: cfg.APIKey,
|
||||
Enabled: cfg.Enabled,
|
||||
Dashboard: cfg.Dashboard,
|
||||
}
|
||||
}
|
||||
106
internal/pkg/gorse/embedding.go
Normal file
106
internal/pkg/gorse/embedding.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package gorse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmbeddingConfig embedding服务配置
|
||||
type EmbeddingConfig struct {
|
||||
APIKey string
|
||||
URL string
|
||||
Model string
|
||||
}
|
||||
|
||||
var defaultEmbeddingConfig = EmbeddingConfig{
|
||||
APIKey: "sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD",
|
||||
URL: "https://api.littlelan.cn/v1/embeddings",
|
||||
Model: "BAAI/bge-m3",
|
||||
}
|
||||
|
||||
// SetEmbeddingConfig 设置embedding配置
|
||||
func SetEmbeddingConfig(apiKey, url, model string) {
|
||||
if apiKey != "" {
|
||||
defaultEmbeddingConfig.APIKey = apiKey
|
||||
}
|
||||
if url != "" {
|
||||
defaultEmbeddingConfig.URL = url
|
||||
}
|
||||
if model != "" {
|
||||
defaultEmbeddingConfig.Model = model
|
||||
}
|
||||
}
|
||||
|
||||
// GetEmbedding 获取文本的embedding
|
||||
func GetEmbedding(text string) ([]float64, error) {
|
||||
type embeddingRequest struct {
|
||||
Input string `json:"input"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type embeddingResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
reqBody := embeddingRequest{
|
||||
Input: text,
|
||||
Model: defaultEmbeddingConfig.Model,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", defaultEmbeddingConfig.URL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+defaultEmbeddingConfig.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("embedding API error: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result embeddingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Data) == 0 {
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
return result.Data[0].Embedding, nil
|
||||
}
|
||||
|
||||
// InitEmbeddingWithConfig 从应用配置初始化embedding
|
||||
func InitEmbeddingWithConfig(apiKey, url, model string) {
|
||||
if apiKey == "" {
|
||||
log.Println("[WARN] Gorse embedding API key not set, using default")
|
||||
}
|
||||
defaultEmbeddingConfig.APIKey = apiKey
|
||||
if url != "" {
|
||||
defaultEmbeddingConfig.URL = url
|
||||
}
|
||||
if model != "" {
|
||||
defaultEmbeddingConfig.Model = model
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user