nandi/gleanpublic Fork 0
2cc88ae
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Extract standalone services from cluster in own pagesUnverified

Julien Robert committed 2026-05-10T23:38:58+02:00 Browse files
2cc88ae parent: 465d821
modified docs/specs.md +5 -3
@@ -994,17 +994,19 @@ glean/
994994 │ │ └── scraper.go # Full article content scraper
995995 │ ├── metrics/
996996 │ │ └── metrics.go # Prometheus metrics definitions
997+│ ├── ai/
998+│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
999+│ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
9971000 │ ├── cluster/
9981001 │ │ ├── jaccard.go # Jaccard similarity computation
999-│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation
1000-│ │ ├── llm.go # LLM client for language detection and text tasks
10011002 │ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost, language detection
10021003 │ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)
10031004 │ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
1004-│ │ ├── dismiss.go # Dismiss + impression tracking
10051005 │ │ ├── weights.go # Bandit-style signal weight auto-tuning
10061006 │ │ ├── diversity.go # Post-query domain/category diversity filtering
10071007 │ │ └── cron.go # Background recomputation scheduler
1008+│ ├── feedback/
1009+│ │ └── feedback.go # Dismiss + impression tracking service
10081010 │ ├── server/
10091011 │ │ ├── server.go # HTTP server, router setup
10101012 │ │ ├── auth_handler.go # OAuth login/callback/register
@@ -994,17 +994,19 @@ glean/
994 │ │ └── scraper.go # Full article content scraper994 │ │ └── scraper.go # Full article content scraper
995 │ ├── metrics/995 │ ├── metrics/
996 │ │ └── metrics.go # Prometheus metrics definitions996 │ │ └── metrics.go # Prometheus metrics definitions
997+│ ├── ai/
998+│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
999+│ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
997 │ ├── cluster/1000 │ ├── cluster/
998 │ │ ├── jaccard.go # Jaccard similarity computation1001 │ │ ├── jaccard.go # Jaccard similarity computation
999-│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation
1000-│ │ ├── llm.go # LLM client for language detection and text tasks
1001 │ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost, language detection1002 │ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost, language detection
1002 │ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)1003 │ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)
1003 │ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)1004 │ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
1004-│ │ ├── dismiss.go # Dismiss + impression tracking
1005 │ │ ├── weights.go # Bandit-style signal weight auto-tuning1005 │ │ ├── weights.go # Bandit-style signal weight auto-tuning
1006 │ │ ├── diversity.go # Post-query domain/category diversity filtering1006 │ │ ├── diversity.go # Post-query domain/category diversity filtering
1007 │ │ └── cron.go # Background recomputation scheduler1007 │ │ └── cron.go # Background recomputation scheduler
1008+│ ├── feedback/
1009+│ │ └── feedback.go # Dismiss + impression tracking service
1008 │ ├── server/1010 │ ├── server/
1009 │ │ ├── server.go # HTTP server, router setup1011 │ │ ├── server.go # HTTP server, router setup
1010 │ │ ├── auth_handler.go # OAuth login/callback/register1012 │ │ ├── auth_handler.go # OAuth login/callback/register
renamed internal/ai/embed.go +10 -11
similarity index 76%
rename from internal/cluster/embed.go
rename to internal/ai/embed.go
@@ -1,4 +1,4 @@
1-package cluster
1+package ai
22
33 import (
44 "context"
@@ -9,26 +9,25 @@ import (
99 "github.com/openai/openai-go/option"
1010 )
1111
12-// Embedder generates vector embeddings for text inputs.
1312 type Embedder interface {
1413 Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error)
1514 Dimension() int
1615 }
1716
18-type EmbedderClient struct {
17+type embedder struct {
1918 client openai.Client
2019 model string
2120 dimension int
2221 }
2322
24-type EmbedderClientConfig struct {
23+type EmbedConfig struct {
2524 BaseURL string
2625 APIKey string
2726 Model string
2827 Dimension int
2928 }
3029
31-func NewEmbedderClient(cfg EmbedderClientConfig) *EmbedderClient {
30+func NewEmbedder(cfg EmbedConfig) Embedder {
3231 opts := []option.RequestOption{}
3332 if cfg.BaseURL != "" {
3433 opts = append(opts, option.WithBaseURL(cfg.BaseURL))
@@ -36,18 +35,18 @@ func NewEmbedderClient(cfg EmbedderClientConfig) *EmbedderClient {
3635 if cfg.APIKey != "" {
3736 opts = append(opts, option.WithAPIKey(cfg.APIKey))
3837 }
39- return &EmbedderClient{
38+ return &embedder{
4039 client: openai.NewClient(opts...),
4140 model: cfg.Model,
4241 dimension: cfg.Dimension,
4342 }
4443 }
4544
46-func (e *EmbedderClient) Dimension() int {
45+func (e *embedder) Dimension() int {
4746 return e.dimension
4847 }
4948
50-func (e *EmbedderClient) Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error) {
49+func (e *embedder) Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error) {
5150 inputs := texts
5251 if instruction != "" {
5352 inputs = make([]string, len(texts))
@@ -75,11 +74,11 @@ func (e *EmbedderClient) Embed(ctx context.Context, texts []string, instruction
7574 return embeddings, nil
7675 }
7776
78-func avgEmbeddings(blobs [][]byte, dim int) ([]byte, error) {
77+func AvgEmbeddings(blobs [][]byte, dim int) ([]byte, error) {
7978 sum := make([]float32, dim)
8079 count := 0
8180 for _, blob := range blobs {
82- v := bytesToFloat32s(blob, dim)
81+ v := BytesToFloat32s(blob, dim)
8382 if v == nil {
8483 continue
8584 }
@@ -97,7 +96,7 @@ func avgEmbeddings(blobs [][]byte, dim int) ([]byte, error) {
9796 return vec.SerializeFloat32(sum)
9897 }
9998
100-func bytesToFloat32s(data []byte, expectedDim int) []float32 {
99+func BytesToFloat32s(data []byte, expectedDim int) []float32 {
101100 if len(data) != expectedDim*4 {
102101 return nil
103102 }
similarity index 76%
rename from internal/cluster/embed.go
rename to internal/ai/embed.go
@@ -1,4 +1,4 @@
1-package cluster1+package ai
2 2
3 import (3 import (
4 "context"4 "context"
@@ -9,26 +9,25 @@ import (
9 "github.com/openai/openai-go/option"9 "github.com/openai/openai-go/option"
10 )10 )
11 11
12-// Embedder generates vector embeddings for text inputs.
13 type Embedder interface {12 type Embedder interface {
14 Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error)13 Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error)
15 Dimension() int14 Dimension() int
16 }15 }
17 16
18-type EmbedderClient struct {17+type embedder struct {
19 client openai.Client18 client openai.Client
20 model string19 model string
21 dimension int20 dimension int
22 }21 }
23 22
24-type EmbedderClientConfig struct {23+type EmbedConfig struct {
25 BaseURL string24 BaseURL string
26 APIKey string25 APIKey string
27 Model string26 Model string
28 Dimension int27 Dimension int
29 }28 }
30 29
31-func NewEmbedderClient(cfg EmbedderClientConfig) *EmbedderClient {30+func NewEmbedder(cfg EmbedConfig) Embedder {
32 opts := []option.RequestOption{}31 opts := []option.RequestOption{}
33 if cfg.BaseURL != "" {32 if cfg.BaseURL != "" {
34 opts = append(opts, option.WithBaseURL(cfg.BaseURL))33 opts = append(opts, option.WithBaseURL(cfg.BaseURL))
@@ -36,18 +35,18 @@ func NewEmbedderClient(cfg EmbedderClientConfig) *EmbedderClient {
36 if cfg.APIKey != "" {35 if cfg.APIKey != "" {
37 opts = append(opts, option.WithAPIKey(cfg.APIKey))36 opts = append(opts, option.WithAPIKey(cfg.APIKey))
38 }37 }
39- return &EmbedderClient{38+ return &embedder{
40 client: openai.NewClient(opts...),39 client: openai.NewClient(opts...),
41 model: cfg.Model,40 model: cfg.Model,
42 dimension: cfg.Dimension,41 dimension: cfg.Dimension,
43 }42 }
44 }43 }
45 44
46-func (e *EmbedderClient) Dimension() int {45+func (e *embedder) Dimension() int {
47 return e.dimension46 return e.dimension
48 }47 }
49 48
50-func (e *EmbedderClient) Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error) {49+func (e *embedder) Embed(ctx context.Context, texts []string, instruction string) ([][]float32, error) {
51 inputs := texts50 inputs := texts
52 if instruction != "" {51 if instruction != "" {
53 inputs = make([]string, len(texts))52 inputs = make([]string, len(texts))
@@ -75,11 +74,11 @@ func (e *EmbedderClient) Embed(ctx context.Context, texts []string, instruction
75 return embeddings, nil74 return embeddings, nil
76 }75 }
77 76
78-func avgEmbeddings(blobs [][]byte, dim int) ([]byte, error) {77+func AvgEmbeddings(blobs [][]byte, dim int) ([]byte, error) {
79 sum := make([]float32, dim)78 sum := make([]float32, dim)
80 count := 079 count := 0
81 for _, blob := range blobs {80 for _, blob := range blobs {
82- v := bytesToFloat32s(blob, dim)81+ v := BytesToFloat32s(blob, dim)
83 if v == nil {82 if v == nil {
84 continue83 continue
85 }84 }
@@ -97,7 +96,7 @@ func avgEmbeddings(blobs [][]byte, dim int) ([]byte, error) {
97 return vec.SerializeFloat32(sum)96 return vec.SerializeFloat32(sum)
98 }97 }
99 98
100-func bytesToFloat32s(data []byte, expectedDim int) []float32 {99+func BytesToFloat32s(data []byte, expectedDim int) []float32 {
101 if len(data) != expectedDim*4 {100 if len(data) != expectedDim*4 {
102 return nil101 return nil
103 }102 }
renamed internal/ai/llm.go +10 -6
similarity index 84%
rename from internal/cluster/llm.go
rename to internal/ai/llm.go
@@ -1,4 +1,4 @@
1-package cluster
1+package ai
22
33 import (
44 "context"
@@ -11,18 +11,22 @@ import (
1111 "pkg.rbrt.fr/glean/internal/langdetect"
1212 )
1313
14-type LLMClient struct {
14+type TextModel interface {
15+ DetectLanguages(ctx context.Context, texts []string) ([]string, error)
16+}
17+
18+type llm struct {
1519 client openai.Client
1620 model string
1721 }
1822
19-type LLMClientConfig struct {
23+type LLMConfig struct {
2024 BaseURL string
2125 APIKey string
2226 Model string
2327 }
2428
25-func NewLLMClient(cfg LLMClientConfig) *LLMClient {
29+func NewLLM(cfg LLMConfig) TextModel {
2630 opts := []option.RequestOption{}
2731 if cfg.BaseURL != "" {
2832 opts = append(opts, option.WithBaseURL(cfg.BaseURL))
@@ -30,13 +34,13 @@ func NewLLMClient(cfg LLMClientConfig) *LLMClient {
3034 if cfg.APIKey != "" {
3135 opts = append(opts, option.WithAPIKey(cfg.APIKey))
3236 }
33- return &LLMClient{
37+ return &llm{
3438 client: openai.NewClient(opts...),
3539 model: cfg.Model,
3640 }
3741 }
3842
39-func (c *LLMClient) DetectLanguages(ctx context.Context, texts []string) ([]string, error) {
43+func (c *llm) DetectLanguages(ctx context.Context, texts []string) ([]string, error) {
4044 var b strings.Builder
4145 b.WriteString("For each text below, respond with ONLY the ISO 639-1 language code (e.g. en, fr, de, es, pt, it, ru, ja, zh, ko, ar). One code per line, same order as input. If uncertain, respond with 'en'.\n\n")
4246 for i, t := range texts {
similarity index 84%
rename from internal/cluster/llm.go
rename to internal/ai/llm.go
@@ -1,4 +1,4 @@
1-package cluster1+package ai
2 2
3 import (3 import (
4 "context"4 "context"
@@ -11,18 +11,22 @@ import (
11 "pkg.rbrt.fr/glean/internal/langdetect"11 "pkg.rbrt.fr/glean/internal/langdetect"
12 )12 )
13 13
14-type LLMClient struct {14+type TextModel interface {
15+ DetectLanguages(ctx context.Context, texts []string) ([]string, error)
16+}
17+
18+type llm struct {
15 client openai.Client19 client openai.Client
16 model string20 model string
17 }21 }
18 22
19-type LLMClientConfig struct {23+type LLMConfig struct {
20 BaseURL string24 BaseURL string
21 APIKey string25 APIKey string
22 Model string26 Model string
23 }27 }
24 28
25-func NewLLMClient(cfg LLMClientConfig) *LLMClient {29+func NewLLM(cfg LLMConfig) TextModel {
26 opts := []option.RequestOption{}30 opts := []option.RequestOption{}
27 if cfg.BaseURL != "" {31 if cfg.BaseURL != "" {
28 opts = append(opts, option.WithBaseURL(cfg.BaseURL))32 opts = append(opts, option.WithBaseURL(cfg.BaseURL))
@@ -30,13 +34,13 @@ func NewLLMClient(cfg LLMClientConfig) *LLMClient {
30 if cfg.APIKey != "" {34 if cfg.APIKey != "" {
31 opts = append(opts, option.WithAPIKey(cfg.APIKey))35 opts = append(opts, option.WithAPIKey(cfg.APIKey))
32 }36 }
33- return &LLMClient{37+ return &llm{
34 client: openai.NewClient(opts...),38 client: openai.NewClient(opts...),
35 model: cfg.Model,39 model: cfg.Model,
36 }40 }
37 }41 }
38 42
39-func (c *LLMClient) DetectLanguages(ctx context.Context, texts []string) ([]string, error) {43+func (c *llm) DetectLanguages(ctx context.Context, texts []string) ([]string, error) {
40 var b strings.Builder44 var b strings.Builder
41 b.WriteString("For each text below, respond with ONLY the ISO 639-1 language code (e.g. en, fr, de, es, pt, it, ru, ja, zh, ko, ar). One code per line, same order as input. If uncertain, respond with 'en'.\n\n")45 b.WriteString("For each text below, respond with ONLY the ISO 639-1 language code (e.g. en, fr, de, es, pt, it, ru, ja, zh, ko, ar). One code per line, same order as input. If uncertain, respond with 'en'.\n\n")
42 for i, t := range texts {46 for i, t := range texts {
modified internal/cluster/article.go +3 -1
@@ -8,6 +8,8 @@ import (
88 "strings"
99
1010 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
11+
12+ "pkg.rbrt.fr/glean/internal/ai"
1113 )
1214
1315 // embedBatchSize caps how many texts are sent in a single embedding API call.
@@ -194,7 +196,7 @@ func (e *Engine) populateContentBoost(ctx context.Context, conn *sql.Conn, userD
194196 return nil
195197 }
196198
197- queryBlob, err := avgEmbeddings(blobs, dim)
199+ queryBlob, err := ai.AvgEmbeddings(blobs, dim)
198200 if err != nil {
199201 return fmt.Errorf("serialize query vector: %w", err)
200202 }
@@ -8,6 +8,8 @@ import (
8 "strings"8 "strings"
9 9
10 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"10 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
11+
12+ "pkg.rbrt.fr/glean/internal/ai"
11 )13 )
12 14
13 // embedBatchSize caps how many texts are sent in a single embedding API call.15 // embedBatchSize caps how many texts are sent in a single embedding API call.
@@ -194,7 +196,7 @@ func (e *Engine) populateContentBoost(ctx context.Context, conn *sql.Conn, userD
194 return nil196 return nil
195 }197 }
196 198
197- queryBlob, err := avgEmbeddings(blobs, dim)199+ queryBlob, err := ai.AvgEmbeddings(blobs, dim)
198 if err != nil {200 if err != nil {
199 return fmt.Errorf("serialize query vector: %w", err)201 return fmt.Errorf("serialize query vector: %w", err)
200 }202 }
modified internal/cluster/cron.go +1 -1
@@ -59,7 +59,7 @@ func (c *Cron) Run(ctx context.Context) error {
5959 if err := c.engine.ComputeFollowDistances(ctx); err != nil {
6060 c.logger.Error("follow distances failed", "error", err)
6161 }
62- if err := c.engine.AutoDismissStale(ctx, 5, 5); err != nil {
62+ if err := c.engine.feedback.AutoDismissStale(ctx, 5, 5); err != nil {
6363 c.engine.logger.Error("auto dismiss failed", "error", err)
6464 }
6565 c.engine.mu.Unlock()
@@ -59,7 +59,7 @@ func (c *Cron) Run(ctx context.Context) error {
59 if err := c.engine.ComputeFollowDistances(ctx); err != nil {59 if err := c.engine.ComputeFollowDistances(ctx); err != nil {
60 c.logger.Error("follow distances failed", "error", err)60 c.logger.Error("follow distances failed", "error", err)
61 }61 }
62- if err := c.engine.AutoDismissStale(ctx, 5, 5); err != nil {62+ if err := c.engine.feedback.AutoDismissStale(ctx, 5, 5); err != nil {
63 c.engine.logger.Error("auto dismiss failed", "error", err)63 c.engine.logger.Error("auto dismiss failed", "error", err)
64 }64 }
65 c.engine.mu.Unlock()65 c.engine.mu.Unlock()
modified internal/cluster/jaccard.go +7 -3
@@ -10,7 +10,9 @@ import (
1010
1111 "github.com/hashicorp/golang-lru/v2/expirable"
1212
13+ "pkg.rbrt.fr/glean/internal/ai"
1314 "pkg.rbrt.fr/glean/internal/db"
15+ "pkg.rbrt.fr/glean/internal/feedback"
1416 )
1517
1618 // Config controls weights used during similarity computation (feed similarity
@@ -44,8 +46,9 @@ type Engine struct {
4446 mu sync.Mutex
4547 config Config
4648
47- embedder Embedder
48- llm *LLMClient
49+ embedder ai.Embedder
50+ llm ai.TextModel
51+ feedback *feedback.Service
4952
5053 feedCache *expirable.LRU[string, []*FeedRecommendation]
5154 peopleCache *expirable.LRU[string, []*PersonRecommendation]
@@ -57,7 +60,7 @@ type Engine struct {
5760 // recCacheSize is the maximum number of recommendations to cache per user.
5861 const recCacheSize = 512
5962
60-func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm *LLMClient, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
63+func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder ai.Embedder, llm ai.TextModel, fb *feedback.Service, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
6164 return &Engine{
6265 db: sqlDB,
6366 articles: articles,
@@ -65,6 +68,7 @@ func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm
6568 config: config,
6669 embedder: embedder,
6770 llm: llm,
71+ feedback: fb,
6872 feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),
6973 peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),
7074 articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),
@@ -10,7 +10,9 @@ import (
10 10
11 "github.com/hashicorp/golang-lru/v2/expirable"11 "github.com/hashicorp/golang-lru/v2/expirable"
12 12
13+ "pkg.rbrt.fr/glean/internal/ai"
13 "pkg.rbrt.fr/glean/internal/db"14 "pkg.rbrt.fr/glean/internal/db"
15+ "pkg.rbrt.fr/glean/internal/feedback"
14 )16 )
15 17
16 // Config controls weights used during similarity computation (feed similarity18 // Config controls weights used during similarity computation (feed similarity
@@ -44,8 +46,9 @@ type Engine struct {
44 mu sync.Mutex46 mu sync.Mutex
45 config Config47 config Config
46 48
47- embedder Embedder49+ embedder ai.Embedder
48- llm *LLMClient50+ llm ai.TextModel
51+ feedback *feedback.Service
49 52
50 feedCache *expirable.LRU[string, []*FeedRecommendation]53 feedCache *expirable.LRU[string, []*FeedRecommendation]
51 peopleCache *expirable.LRU[string, []*PersonRecommendation]54 peopleCache *expirable.LRU[string, []*PersonRecommendation]
@@ -57,7 +60,7 @@ type Engine struct {
57 // recCacheSize is the maximum number of recommendations to cache per user.60 // recCacheSize is the maximum number of recommendations to cache per user.
58 const recCacheSize = 51261 const recCacheSize = 512
59 62
60-func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm *LLMClient, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {63+func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder ai.Embedder, llm ai.TextModel, fb *feedback.Service, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
61 return &Engine{64 return &Engine{
62 db: sqlDB,65 db: sqlDB,
63 articles: articles,66 articles: articles,
@@ -65,6 +68,7 @@ func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm
65 config: config,68 config: config,
66 embedder: embedder,69 embedder: embedder,
67 llm: llm,70 llm: llm,
71+ feedback: fb,
68 feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),72 feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),
69 peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),73 peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),
70 articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),74 articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),
modified internal/cluster/jaccard_test.go +21 -20
@@ -12,6 +12,7 @@ import (
1212
1313 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
1414 "pkg.rbrt.fr/glean/internal/db"
15+ "pkg.rbrt.fr/glean/internal/feedback"
1516
1617 "gotest.tools/v3/assert"
1718 )
@@ -95,7 +96,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
9596 }
9697
9798 func newTestEngine(dbs *db.Store) *Engine {
98- return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, slog.Default(), time.Hour, DefaultConfig())
99+ return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, feedback.NewService(dbs.SQLDB()), slog.Default(), time.Hour, DefaultConfig())
99100 }
100101
101102 func TestComputeFeedSimilarity(t *testing.T) {
@@ -182,7 +183,7 @@ func TestDismissedFeedsExcluded(t *testing.T) {
182183 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
183184 assert.NilError(t, engine.ComputeUserSimilarity(ctx))
184185
185- assert.NilError(t, engine.DismissFeed(ctx, "did:test:carol", "https://a.com/feed", "not_interested"))
186+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:carol", "https://a.com/feed", "not_interested"))
186187
187188 recs, err := engine.GetFeedRecommendations(ctx, "did:test:carol", 10)
188189 assert.NilError(t, err)
@@ -200,13 +201,13 @@ func TestIsFeedDismissed(t *testing.T) {
200201
201202 engine := newTestEngine(dbs)
202203
203- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")
204+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")
204205 assert.NilError(t, err)
205206 assert.Assert(t, !dismissed, "feed should not be dismissed initially")
206207
207- assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "not_interested"))
208+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "not_interested"))
208209
209- dismissed, err = engine.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")
210+ dismissed, err = engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")
210211 assert.NilError(t, err)
211212 assert.Assert(t, dismissed, "feed should be dismissed after dismiss call")
212213 }
@@ -218,18 +219,18 @@ func TestRecordImpressions(t *testing.T) {
218219
219220 engine := newTestEngine(dbs)
220221
221- impressions := []Impression{
222+ impressions := []feedback.Impression{
222223 {TargetType: "feed", TargetID: "https://a.com/feed"},
223224 {TargetType: "feed", TargetID: "https://b.com/feed"},
224225 }
225- assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
226+ assert.NilError(t, engine.feedback.RecordImpressions(ctx, "did:test:alice", impressions))
226227
227228 var count int
228229 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
229230 `SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
230231 assert.Equal(t, count, 2)
231232
232- assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
233+ assert.NilError(t, engine.feedback.RecordImpressions(ctx, "did:test:alice", impressions))
233234
234235 var shownCount int
235236 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -244,10 +245,10 @@ func TestMarkImpressionActed(t *testing.T) {
244245
245246 engine := newTestEngine(dbs)
246247
247- impressions := []Impression{{TargetType: "feed", TargetID: "https://a.com/feed"}}
248- assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
248+ impressions := []feedback.Impression{{TargetType: "feed", TargetID: "https://a.com/feed"}}
249+ assert.NilError(t, engine.feedback.RecordImpressions(ctx, "did:test:alice", impressions))
249250
250- assert.NilError(t, engine.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))
251+ assert.NilError(t, engine.feedback.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))
251252
252253 var acted bool
253254 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -313,9 +314,9 @@ func TestAutoDismissStale(t *testing.T) {
313314 `)
314315 assert.NilError(t, err)
315316
316- assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
317+ assert.NilError(t, engine.feedback.AutoDismissStale(ctx, 5, 5))
317318
318- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")
319+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")
319320 assert.NilError(t, err)
320321 assert.Assert(t, dismissed, "stale recommendation should be auto-dismissed")
321322 }
@@ -333,9 +334,9 @@ func TestAutoDismissStale_DoesNotDismissRecent(t *testing.T) {
333334 `)
334335 assert.NilError(t, err)
335336
336- assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
337+ assert.NilError(t, engine.feedback.AutoDismissStale(ctx, 5, 5))
337338
338- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")
339+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")
339340 assert.NilError(t, err)
340341 assert.Assert(t, !dismissed, "recent impression should not be auto-dismissed")
341342 }
@@ -353,9 +354,9 @@ func TestAutoDismissStale_DoesNotDismissActed(t *testing.T) {
353354 `)
354355 assert.NilError(t, err)
355356
356- assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
357+ assert.NilError(t, engine.feedback.AutoDismissStale(ctx, 5, 5))
357358
358- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")
359+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")
359360 assert.NilError(t, err)
360361 assert.Assert(t, !dismissed, "acted recommendation should not be auto-dismissed")
361362 }
@@ -503,7 +504,7 @@ func TestDismissArticle(t *testing.T) {
503504
504505 engine := newTestEngine(dbs)
505506
506- assert.NilError(t, engine.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))
507+ assert.NilError(t, engine.feedback.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))
507508
508509 var count int
509510 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -531,8 +532,8 @@ func TestDismissFeed_Idempotent(t *testing.T) {
531532
532533 engine := newTestEngine(dbs)
533534
534- assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason1"))
535- assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))
535+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason1"))
536+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))
536537
537538 var count int
538539 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -12,6 +12,7 @@ import (
12 12
13 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"13 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
14 "pkg.rbrt.fr/glean/internal/db"14 "pkg.rbrt.fr/glean/internal/db"
15+ "pkg.rbrt.fr/glean/internal/feedback"
15 16
16 "gotest.tools/v3/assert"17 "gotest.tools/v3/assert"
17 )18 )
@@ -95,7 +96,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
95 }96 }
96 97
97 func newTestEngine(dbs *db.Store) *Engine {98 func newTestEngine(dbs *db.Store) *Engine {
98- return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, slog.Default(), time.Hour, DefaultConfig())99+ return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, feedback.NewService(dbs.SQLDB()), slog.Default(), time.Hour, DefaultConfig())
99 }100 }
100 101
101 func TestComputeFeedSimilarity(t *testing.T) {102 func TestComputeFeedSimilarity(t *testing.T) {
@@ -182,7 +183,7 @@ func TestDismissedFeedsExcluded(t *testing.T) {
182 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))183 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
183 assert.NilError(t, engine.ComputeUserSimilarity(ctx))184 assert.NilError(t, engine.ComputeUserSimilarity(ctx))
184 185
185- assert.NilError(t, engine.DismissFeed(ctx, "did:test:carol", "https://a.com/feed", "not_interested"))186+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:carol", "https://a.com/feed", "not_interested"))
186 187
187 recs, err := engine.GetFeedRecommendations(ctx, "did:test:carol", 10)188 recs, err := engine.GetFeedRecommendations(ctx, "did:test:carol", 10)
188 assert.NilError(t, err)189 assert.NilError(t, err)
@@ -200,13 +201,13 @@ func TestIsFeedDismissed(t *testing.T) {
200 201
201 engine := newTestEngine(dbs)202 engine := newTestEngine(dbs)
202 203
203- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")204+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")
204 assert.NilError(t, err)205 assert.NilError(t, err)
205 assert.Assert(t, !dismissed, "feed should not be dismissed initially")206 assert.Assert(t, !dismissed, "feed should not be dismissed initially")
206 207
207- assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "not_interested"))208+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "not_interested"))
208 209
209- dismissed, err = engine.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")210+ dismissed, err = engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://a.com/feed")
210 assert.NilError(t, err)211 assert.NilError(t, err)
211 assert.Assert(t, dismissed, "feed should be dismissed after dismiss call")212 assert.Assert(t, dismissed, "feed should be dismissed after dismiss call")
212 }213 }
@@ -218,18 +219,18 @@ func TestRecordImpressions(t *testing.T) {
218 219
219 engine := newTestEngine(dbs)220 engine := newTestEngine(dbs)
220 221
221- impressions := []Impression{222+ impressions := []feedback.Impression{
222 {TargetType: "feed", TargetID: "https://a.com/feed"},223 {TargetType: "feed", TargetID: "https://a.com/feed"},
223 {TargetType: "feed", TargetID: "https://b.com/feed"},224 {TargetType: "feed", TargetID: "https://b.com/feed"},
224 }225 }
225- assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))226+ assert.NilError(t, engine.feedback.RecordImpressions(ctx, "did:test:alice", impressions))
226 227
227 var count int228 var count int
228 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,229 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
229 `SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))230 `SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
230 assert.Equal(t, count, 2)231 assert.Equal(t, count, 2)
231 232
232- assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))233+ assert.NilError(t, engine.feedback.RecordImpressions(ctx, "did:test:alice", impressions))
233 234
234 var shownCount int235 var shownCount int
235 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,236 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -244,10 +245,10 @@ func TestMarkImpressionActed(t *testing.T) {
244 245
245 engine := newTestEngine(dbs)246 engine := newTestEngine(dbs)
246 247
247- impressions := []Impression{{TargetType: "feed", TargetID: "https://a.com/feed"}}248+ impressions := []feedback.Impression{{TargetType: "feed", TargetID: "https://a.com/feed"}}
248- assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))249+ assert.NilError(t, engine.feedback.RecordImpressions(ctx, "did:test:alice", impressions))
249 250
250- assert.NilError(t, engine.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))251+ assert.NilError(t, engine.feedback.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))
251 252
252 var acted bool253 var acted bool
253 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,254 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -313,9 +314,9 @@ func TestAutoDismissStale(t *testing.T) {
313 `)314 `)
314 assert.NilError(t, err)315 assert.NilError(t, err)
315 316
316- assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))317+ assert.NilError(t, engine.feedback.AutoDismissStale(ctx, 5, 5))
317 318
318- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")319+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")
319 assert.NilError(t, err)320 assert.NilError(t, err)
320 assert.Assert(t, dismissed, "stale recommendation should be auto-dismissed")321 assert.Assert(t, dismissed, "stale recommendation should be auto-dismissed")
321 }322 }
@@ -333,9 +334,9 @@ func TestAutoDismissStale_DoesNotDismissRecent(t *testing.T) {
333 `)334 `)
334 assert.NilError(t, err)335 assert.NilError(t, err)
335 336
336- assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))337+ assert.NilError(t, engine.feedback.AutoDismissStale(ctx, 5, 5))
337 338
338- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")339+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")
339 assert.NilError(t, err)340 assert.NilError(t, err)
340 assert.Assert(t, !dismissed, "recent impression should not be auto-dismissed")341 assert.Assert(t, !dismissed, "recent impression should not be auto-dismissed")
341 }342 }
@@ -353,9 +354,9 @@ func TestAutoDismissStale_DoesNotDismissActed(t *testing.T) {
353 `)354 `)
354 assert.NilError(t, err)355 assert.NilError(t, err)
355 356
356- assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))357+ assert.NilError(t, engine.feedback.AutoDismissStale(ctx, 5, 5))
357 358
358- dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")359+ dismissed, err := engine.feedback.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")
359 assert.NilError(t, err)360 assert.NilError(t, err)
360 assert.Assert(t, !dismissed, "acted recommendation should not be auto-dismissed")361 assert.Assert(t, !dismissed, "acted recommendation should not be auto-dismissed")
361 }362 }
@@ -503,7 +504,7 @@ func TestDismissArticle(t *testing.T) {
503 504
504 engine := newTestEngine(dbs)505 engine := newTestEngine(dbs)
505 506
506- assert.NilError(t, engine.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))507+ assert.NilError(t, engine.feedback.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))
507 508
508 var count int509 var count int
509 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,510 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
@@ -531,8 +532,8 @@ func TestDismissFeed_Idempotent(t *testing.T) {
531 532
532 engine := newTestEngine(dbs)533 engine := newTestEngine(dbs)
533 534
534- assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason1"))535+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason1"))
535- assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))536+ assert.NilError(t, engine.feedback.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))
536 537
537 var count int538 var count int
538 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,539 assert.NilError(t, dbs.SQLDB().QueryRowContext(ctx,
modified internal/cluster/scoring.go +2 -1
@@ -7,6 +7,7 @@ import (
77 "strings"
88 "time"
99
10+ "pkg.rbrt.fr/glean/internal/ai"
1011 "pkg.rbrt.fr/glean/internal/db"
1112 )
1213
@@ -384,7 +385,7 @@ func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, li
384385 subSet[u] = true
385386 }
386387
387- queryBlob, err := avgEmbeddings(blobs, dim)
388+ queryBlob, err := ai.AvgEmbeddings(blobs, dim)
388389 if err != nil {
389390 return nil, fmt.Errorf("serialize query vector: %w", err)
390391 }
@@ -7,6 +7,7 @@ import (
7 "strings"7 "strings"
8 "time"8 "time"
9 9
10+ "pkg.rbrt.fr/glean/internal/ai"
10 "pkg.rbrt.fr/glean/internal/db"11 "pkg.rbrt.fr/glean/internal/db"
11 )12 )
12 13
@@ -384,7 +385,7 @@ func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, li
384 subSet[u] = true385 subSet[u] = true
385 }386 }
386 387
387- queryBlob, err := avgEmbeddings(blobs, dim)388+ queryBlob, err := ai.AvgEmbeddings(blobs, dim)
388 if err != nil {389 if err != nil {
389 return nil, fmt.Errorf("serialize query vector: %w", err)390 return nil, fmt.Errorf("serialize query vector: %w", err)
390 }391 }
renamed internal/feedback/feedback.go +26 -23
similarity index 53%
rename from internal/cluster/dismiss.go
rename to internal/feedback/feedback.go
@@ -1,20 +1,26 @@
1-package cluster
1+package feedback
22
33 import (
44 "context"
5+ "database/sql"
56 "time"
67 )
78
8-// Impression records that a recommendation was shown to a user.
99 type Impression struct {
1010 TargetType string
1111 TargetID string
1212 }
1313
14-// Dismiss records that the user dismissed a recommendation. targetType is one
15-// of "feed", "article", "person".
16-func (e *Engine) Dismiss(ctx context.Context, userDID, targetType, targetID, reason string) error {
17- _, err := e.db.ExecContext(ctx, `
14+type Service struct {
15+ db *sql.DB
16+}
17+
18+func NewService(db *sql.DB) *Service {
19+ return &Service{db: db}
20+}
21+
22+func (s *Service) Dismiss(ctx context.Context, userDID, targetType, targetID, reason string) error {
23+ _, err := s.db.ExecContext(ctx, `
1824 INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)
1925 VALUES (?, ?, ?, ?)
2026 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
@@ -22,20 +28,20 @@ func (e *Engine) Dismiss(ctx context.Context, userDID, targetType, targetID, rea
2228 return err
2329 }
2430
25-func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {
26- return e.Dismiss(ctx, userDID, "feed", feedURL, reason)
31+func (s *Service) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {
32+ return s.Dismiss(ctx, userDID, "feed", feedURL, reason)
2733 }
2834
29-func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {
30- return e.Dismiss(ctx, userDID, "article", articleURL, reason)
35+func (s *Service) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {
36+ return s.Dismiss(ctx, userDID, "article", articleURL, reason)
3137 }
3238
33-func (e *Engine) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error {
34- return e.Dismiss(ctx, userDID, "person", targetDID, reason)
39+func (s *Service) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error {
40+ return s.Dismiss(ctx, userDID, "person", targetDID, reason)
3541 }
3642
37-func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {
38- tx, err := e.db.BeginTx(ctx, nil)
43+func (s *Service) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {
44+ tx, err := s.db.BeginTx(ctx, nil)
3945 if err != nil {
4046 return err
4147 }
@@ -56,21 +62,18 @@ func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressi
5662 return tx.Commit()
5763 }
5864
59-func (e *Engine) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {
60- _, err := e.db.ExecContext(ctx, `
65+func (s *Service) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {
66+ _, err := s.db.ExecContext(ctx, `
6167 UPDATE main.recommendation_impressions SET acted = 1
6268 WHERE user_did = ? AND target_type = ? AND target_id = ?
6369 `, userDID, targetType, targetID)
6470 return err
6571 }
6672
67-// AutoDismissStale marks recommendations as dismissed if they were shown at
68-// least minShownCount times over more than maxAgeDays without the user acting
69-// on them.
70-func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {
73+func (s *Service) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {
7174 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
7275
73- _, err := e.db.ExecContext(ctx, `
76+ _, err := s.db.ExecContext(ctx, `
7477 INSERT OR IGNORE INTO main.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
7578 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP
7679 FROM main.recommendation_impressions
@@ -81,9 +84,9 @@ func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAge
8184 return err
8285 }
8386
84-func (e *Engine) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {
87+func (s *Service) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {
8588 var count int
86- err := e.db.QueryRowContext(ctx, `
89+ err := s.db.QueryRowContext(ctx, `
8790 SELECT COUNT(1) FROM main.dismissed_recommendations
8891 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?
8992 `, userDID, feedURL).Scan(&count)
similarity index 53%
rename from internal/cluster/dismiss.go
rename to internal/feedback/feedback.go
@@ -1,20 +1,26 @@
1-package cluster1+package feedback
2 2
3 import (3 import (
4 "context"4 "context"
5+ "database/sql"
5 "time"6 "time"
6 )7 )
7 8
8-// Impression records that a recommendation was shown to a user.
9 type Impression struct {9 type Impression struct {
10 TargetType string10 TargetType string
11 TargetID string11 TargetID string
12 }12 }
13 13
14-// Dismiss records that the user dismissed a recommendation. targetType is one14+type Service struct {
15-// of "feed", "article", "person".15+ db *sql.DB
16-func (e *Engine) Dismiss(ctx context.Context, userDID, targetType, targetID, reason string) error {16+}
17- _, err := e.db.ExecContext(ctx, `17+
18+func NewService(db *sql.DB) *Service {
19+ return &Service{db: db}
20+}
21+
22+func (s *Service) Dismiss(ctx context.Context, userDID, targetType, targetID, reason string) error {
23+ _, err := s.db.ExecContext(ctx, `
18 INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)24 INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)
19 VALUES (?, ?, ?, ?)25 VALUES (?, ?, ?, ?)
20 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP26 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
@@ -22,20 +28,20 @@ func (e *Engine) Dismiss(ctx context.Context, userDID, targetType, targetID, rea
22 return err28 return err
23 }29 }
24 30
25-func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {31+func (s *Service) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {
26- return e.Dismiss(ctx, userDID, "feed", feedURL, reason)32+ return s.Dismiss(ctx, userDID, "feed", feedURL, reason)
27 }33 }
28 34
29-func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {35+func (s *Service) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {
30- return e.Dismiss(ctx, userDID, "article", articleURL, reason)36+ return s.Dismiss(ctx, userDID, "article", articleURL, reason)
31 }37 }
32 38
33-func (e *Engine) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error {39+func (s *Service) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error {
34- return e.Dismiss(ctx, userDID, "person", targetDID, reason)40+ return s.Dismiss(ctx, userDID, "person", targetDID, reason)
35 }41 }
36 42
37-func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {43+func (s *Service) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {
38- tx, err := e.db.BeginTx(ctx, nil)44+ tx, err := s.db.BeginTx(ctx, nil)
39 if err != nil {45 if err != nil {
40 return err46 return err
41 }47 }
@@ -56,21 +62,18 @@ func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressi
56 return tx.Commit()62 return tx.Commit()
57 }63 }
58 64
59-func (e *Engine) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {65+func (s *Service) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {
60- _, err := e.db.ExecContext(ctx, `66+ _, err := s.db.ExecContext(ctx, `
61 UPDATE main.recommendation_impressions SET acted = 167 UPDATE main.recommendation_impressions SET acted = 1
62 WHERE user_did = ? AND target_type = ? AND target_id = ?68 WHERE user_did = ? AND target_type = ? AND target_id = ?
63 `, userDID, targetType, targetID)69 `, userDID, targetType, targetID)
64 return err70 return err
65 }71 }
66 72
67-// AutoDismissStale marks recommendations as dismissed if they were shown at73+func (s *Service) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {
68-// least minShownCount times over more than maxAgeDays without the user acting
69-// on them.
70-func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {
71 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)74 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
72 75
73- _, err := e.db.ExecContext(ctx, `76+ _, err := s.db.ExecContext(ctx, `
74 INSERT OR IGNORE INTO main.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)77 INSERT OR IGNORE INTO main.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
75 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP78 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP
76 FROM main.recommendation_impressions79 FROM main.recommendation_impressions
@@ -81,9 +84,9 @@ func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAge
81 return err84 return err
82 }85 }
83 86
84-func (e *Engine) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {87+func (s *Service) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {
85 var count int88 var count int
86- err := e.db.QueryRowContext(ctx, `89+ err := s.db.QueryRowContext(ctx, `
87 SELECT COUNT(1) FROM main.dismissed_recommendations90 SELECT COUNT(1) FROM main.dismissed_recommendations
88 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?91 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?
89 `, userDID, feedURL).Scan(&count)92 `, userDID, feedURL).Scan(&count)
modified internal/server/articles_handler.go +2 -2
@@ -373,7 +373,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
373373 http.Error(w, err.Error(), http.StatusInternalServerError)
374374 return
375375 }
376- if err := s.engine.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
376+ if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
377377 s.logger.Warn("failed to mark impression acted", "error", err)
378378 }
379379 sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
@@ -390,7 +390,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
390390 http.Error(w, err.Error(), http.StatusInternalServerError)
391391 return
392392 }
393- if err := s.engine.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
393+ if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
394394 s.logger.Warn("failed to mark impression acted", "error", err)
395395 }
396396 sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
@@ -373,7 +373,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
373 http.Error(w, err.Error(), http.StatusInternalServerError)373 http.Error(w, err.Error(), http.StatusInternalServerError)
374 return374 return
375 }375 }
376- if err := s.engine.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {376+ if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
377 s.logger.Warn("failed to mark impression acted", "error", err)377 s.logger.Warn("failed to mark impression acted", "error", err)
378 }378 }
379 sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))379 sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
@@ -390,7 +390,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
390 http.Error(w, err.Error(), http.StatusInternalServerError)390 http.Error(w, err.Error(), http.StatusInternalServerError)
391 return391 return
392 }392 }
393- if err := s.engine.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {393+ if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
394 s.logger.Warn("failed to mark impression acted", "error", err)394 s.logger.Warn("failed to mark impression acted", "error", err)
395 }395 }
396 sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))396 sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
modified internal/server/dashboard_handler.go +5 -4
@@ -10,6 +10,7 @@ import (
1010 "pkg.rbrt.fr/glean/internal/atproto"
1111 "pkg.rbrt.fr/glean/internal/cluster"
1212 "pkg.rbrt.fr/glean/internal/db"
13+ "pkg.rbrt.fr/glean/internal/feedback"
1314 )
1415
1516 func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
@@ -102,15 +103,15 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
102103
103104 resolvePeopleHandles(ctx, peopleRecs)
104105
105- var impressions []cluster.Impression
106+ var impressions []feedback.Impression
106107 for _, rec := range articleRecs {
107- impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL})
108+ impressions = append(impressions, feedback.Impression{TargetType: "article", TargetID: rec.URL})
108109 }
109110 for _, rec := range feedRecs {
110- impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL})
111+ impressions = append(impressions, feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL})
111112 }
112113 if len(impressions) > 0 {
113- if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil {
114+ if err := s.feedback.RecordImpressions(ctx, user.DID, impressions); err != nil {
114115 s.logger.Warn("failed to record impressions", "error", err)
115116 }
116117 }
@@ -10,6 +10,7 @@ import (
10 "pkg.rbrt.fr/glean/internal/atproto"10 "pkg.rbrt.fr/glean/internal/atproto"
11 "pkg.rbrt.fr/glean/internal/cluster"11 "pkg.rbrt.fr/glean/internal/cluster"
12 "pkg.rbrt.fr/glean/internal/db"12 "pkg.rbrt.fr/glean/internal/db"
13+ "pkg.rbrt.fr/glean/internal/feedback"
13 )14 )
14 15
15 func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {16 func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
@@ -102,15 +103,15 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
102 103
103 resolvePeopleHandles(ctx, peopleRecs)104 resolvePeopleHandles(ctx, peopleRecs)
104 105
105- var impressions []cluster.Impression106+ var impressions []feedback.Impression
106 for _, rec := range articleRecs {107 for _, rec := range articleRecs {
107- impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL})108+ impressions = append(impressions, feedback.Impression{TargetType: "article", TargetID: rec.URL})
108 }109 }
109 for _, rec := range feedRecs {110 for _, rec := range feedRecs {
110- impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL})111+ impressions = append(impressions, feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL})
111 }112 }
112 if len(impressions) > 0 {113 if len(impressions) > 0 {
113- if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil {114+ if err := s.feedback.RecordImpressions(ctx, user.DID, impressions); err != nil {
114 s.logger.Warn("failed to record impressions", "error", err)115 s.logger.Warn("failed to record impressions", "error", err)
115 }116 }
116 }117 }
modified internal/server/feeds_handler.go +5 -4
@@ -13,6 +13,7 @@ import (
1313 "pkg.rbrt.fr/glean/internal/cluster"
1414 "pkg.rbrt.fr/glean/internal/db"
1515 "pkg.rbrt.fr/glean/internal/feed"
16+ "pkg.rbrt.fr/glean/internal/feedback"
1617 )
1718
1819 func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
@@ -106,11 +107,11 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
106107
107108 g2.Go(func() error {
108109 if len(feedRecs) > 0 {
109- impressions := make([]cluster.Impression, len(feedRecs))
110+ impressions := make([]feedback.Impression, len(feedRecs))
110111 for i, rec := range feedRecs {
111- impressions[i] = cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL}
112+ impressions[i] = feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL}
112113 }
113- if err := s.engine.RecordImpressions(gCtx2, user.DID, impressions); err != nil {
114+ if err := s.feedback.RecordImpressions(gCtx2, user.DID, impressions); err != nil {
114115 s.logger.Warn("failed to record impressions", "error", err)
115116 }
116117 }
@@ -241,7 +242,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
241242
242243 go s.storeFetchResult(context.WithoutCancel(r.Context()), feedURL, result.Feed.SiteURL, result)
243244
244- if err := s.engine.MarkImpressionActed(r.Context(), user.DID, "feed", feedURL); err != nil {
245+ if err := s.feedback.MarkImpressionActed(r.Context(), user.DID, "feed", feedURL); err != nil {
245246 s.logger.Warn("failed to mark impression acted", "error", err)
246247 }
247248 sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID))
@@ -13,6 +13,7 @@ import (
13 "pkg.rbrt.fr/glean/internal/cluster"13 "pkg.rbrt.fr/glean/internal/cluster"
14 "pkg.rbrt.fr/glean/internal/db"14 "pkg.rbrt.fr/glean/internal/db"
15 "pkg.rbrt.fr/glean/internal/feed"15 "pkg.rbrt.fr/glean/internal/feed"
16+ "pkg.rbrt.fr/glean/internal/feedback"
16 )17 )
17 18
18 func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {19 func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
@@ -106,11 +107,11 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
106 107
107 g2.Go(func() error {108 g2.Go(func() error {
108 if len(feedRecs) > 0 {109 if len(feedRecs) > 0 {
109- impressions := make([]cluster.Impression, len(feedRecs))110+ impressions := make([]feedback.Impression, len(feedRecs))
110 for i, rec := range feedRecs {111 for i, rec := range feedRecs {
111- impressions[i] = cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL}112+ impressions[i] = feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL}
112 }113 }
113- if err := s.engine.RecordImpressions(gCtx2, user.DID, impressions); err != nil {114+ if err := s.feedback.RecordImpressions(gCtx2, user.DID, impressions); err != nil {
114 s.logger.Warn("failed to record impressions", "error", err)115 s.logger.Warn("failed to record impressions", "error", err)
115 }116 }
116 }117 }
@@ -241,7 +242,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
241 242
242 go s.storeFetchResult(context.WithoutCancel(r.Context()), feedURL, result.Feed.SiteURL, result)243 go s.storeFetchResult(context.WithoutCancel(r.Context()), feedURL, result.Feed.SiteURL, result)
243 244
244- if err := s.engine.MarkImpressionActed(r.Context(), user.DID, "feed", feedURL); err != nil {245+ if err := s.feedback.MarkImpressionActed(r.Context(), user.DID, "feed", feedURL); err != nil {
245 s.logger.Warn("failed to mark impression acted", "error", err)246 s.logger.Warn("failed to mark impression acted", "error", err)
246 }247 }
247 sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID))248 sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID))
modified internal/server/recs_handler.go +1 -1
@@ -29,7 +29,7 @@ func (s *Server) handleDismiss(w http.ResponseWriter, r *http.Request, field, ta
2929 reason = "not_interested"
3030 }
3131
32- if err := s.engine.Dismiss(r.Context(), user.DID, targetType, targetID, reason); err != nil {
32+ if err := s.feedback.Dismiss(r.Context(), user.DID, targetType, targetID, reason); err != nil {
3333 s.logger.Error("failed to dismiss recommendation", "error", err, "type", targetType)
3434 http.Error(w, err.Error(), http.StatusInternalServerError)
3535 return
@@ -29,7 +29,7 @@ func (s *Server) handleDismiss(w http.ResponseWriter, r *http.Request, field, ta
29 reason = "not_interested"29 reason = "not_interested"
30 }30 }
31 31
32- if err := s.engine.Dismiss(r.Context(), user.DID, targetType, targetID, reason); err != nil {32+ if err := s.feedback.Dismiss(r.Context(), user.DID, targetType, targetID, reason); err != nil {
33 s.logger.Error("failed to dismiss recommendation", "error", err, "type", targetType)33 s.logger.Error("failed to dismiss recommendation", "error", err, "type", targetType)
34 http.Error(w, err.Error(), http.StatusInternalServerError)34 http.Error(w, err.Error(), http.StatusInternalServerError)
35 return35 return
modified internal/server/server.go +3 -0
@@ -26,6 +26,7 @@ import (
2626 "pkg.rbrt.fr/glean/internal/cluster"
2727 "pkg.rbrt.fr/glean/internal/db"
2828 "pkg.rbrt.fr/glean/internal/feed"
29+ "pkg.rbrt.fr/glean/internal/feedback"
2930 "pkg.rbrt.fr/glean/internal/metrics"
3031 "pkg.rbrt.fr/glean/internal/sanitize"
3132 "pkg.rbrt.fr/glean/internal/scraper"
@@ -67,6 +68,7 @@ type Server struct {
6768 fetcher *feed.Fetcher
6869 scheduler *feed.Scheduler
6970 engine *cluster.Engine
71+ feedback *feedback.Service
7072 scraper *scraper.Scraper
7173 clientID string
7274 callbackURL string
@@ -98,6 +100,7 @@ func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Sche
98100 fetcher: fetcher,
99101 scheduler: scheduler,
100102 engine: engine,
103+ feedback: feedback.NewService(dbs.SQLDB()),
101104 scraper: scraper.New(logger),
102105 clientID: clientID,
103106 callbackURL: callbackURL,
@@ -26,6 +26,7 @@ import (
26 "pkg.rbrt.fr/glean/internal/cluster"26 "pkg.rbrt.fr/glean/internal/cluster"
27 "pkg.rbrt.fr/glean/internal/db"27 "pkg.rbrt.fr/glean/internal/db"
28 "pkg.rbrt.fr/glean/internal/feed"28 "pkg.rbrt.fr/glean/internal/feed"
29+ "pkg.rbrt.fr/glean/internal/feedback"
29 "pkg.rbrt.fr/glean/internal/metrics"30 "pkg.rbrt.fr/glean/internal/metrics"
30 "pkg.rbrt.fr/glean/internal/sanitize"31 "pkg.rbrt.fr/glean/internal/sanitize"
31 "pkg.rbrt.fr/glean/internal/scraper"32 "pkg.rbrt.fr/glean/internal/scraper"
@@ -67,6 +68,7 @@ type Server struct {
67 fetcher *feed.Fetcher68 fetcher *feed.Fetcher
68 scheduler *feed.Scheduler69 scheduler *feed.Scheduler
69 engine *cluster.Engine70 engine *cluster.Engine
71+ feedback *feedback.Service
70 scraper *scraper.Scraper72 scraper *scraper.Scraper
71 clientID string73 clientID string
72 callbackURL string74 callbackURL string
@@ -98,6 +100,7 @@ func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Sche
98 fetcher: fetcher,100 fetcher: fetcher,
99 scheduler: scheduler,101 scheduler: scheduler,
100 engine: engine,102 engine: engine,
103+ feedback: feedback.NewService(dbs.SQLDB()),
101 scraper: scraper.New(logger),104 scraper: scraper.New(logger),
102 clientID: clientID,105 clientID: clientID,
103 callbackURL: callbackURL,106 callbackURL: callbackURL,
modified main.go +7 -5
@@ -13,10 +13,12 @@ import (
1313 "syscall"
1414 "time"
1515
16+ "pkg.rbrt.fr/glean/internal/ai"
1617 "pkg.rbrt.fr/glean/internal/atproto"
1718 "pkg.rbrt.fr/glean/internal/cluster"
1819 "pkg.rbrt.fr/glean/internal/db"
1920 "pkg.rbrt.fr/glean/internal/feed"
21+ "pkg.rbrt.fr/glean/internal/feedback"
2022 "pkg.rbrt.fr/glean/internal/server"
2123
2224 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
@@ -73,9 +75,9 @@ func main() {
7375 siteFetcher := atproto.NewStandardSiteFetcher(logger)
7476 scheduler := feed.NewScheduler(storeAdapter, siteFetcher, logger, *fetchInterval, 30*time.Minute)
7577
76- var embedder cluster.Embedder
78+ var embedder ai.Embedder
7779 if embedURL := envOr("GLEAN_EMBED_BASE_URL", ""); embedURL != "" {
78- embedder = cluster.NewEmbedderClient(cluster.EmbedderClientConfig{
80+ embedder = ai.NewEmbedder(ai.EmbedConfig{
7981 BaseURL: embedURL,
8082 APIKey: envOr("GLEAN_EMBED_API_KEY", ""),
8183 Model: envOr("GLEAN_EMBED_MODEL", "text-embedding-3-small"),
@@ -90,16 +92,16 @@ func main() {
9092 }
9193 }
9294
93- var llm *cluster.LLMClient
95+ var llm ai.TextModel
9496 if llmURL := envOr("GLEAN_LLM_BASE_URL", ""); llmURL != "" {
95- llm = cluster.NewLLMClient(cluster.LLMClientConfig{
97+ llm = ai.NewLLM(ai.LLMConfig{
9698 BaseURL: llmURL,
9799 APIKey: envOr("GLEAN_LLM_API_KEY", ""),
98100 Model: envOr("GLEAN_LLM_MODEL", "gpt-4o-mini"),
99101 })
100102 }
101103
102- engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, logger, *clusterInterval, cluster.DefaultConfig())
104+ engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())
103105
104106 fetcher := feed.NewFetcher(siteFetcher)
105107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
@@ -13,10 +13,12 @@ import (
13 "syscall"13 "syscall"
14 "time"14 "time"
15 15
16+ "pkg.rbrt.fr/glean/internal/ai"
16 "pkg.rbrt.fr/glean/internal/atproto"17 "pkg.rbrt.fr/glean/internal/atproto"
17 "pkg.rbrt.fr/glean/internal/cluster"18 "pkg.rbrt.fr/glean/internal/cluster"
18 "pkg.rbrt.fr/glean/internal/db"19 "pkg.rbrt.fr/glean/internal/db"
19 "pkg.rbrt.fr/glean/internal/feed"20 "pkg.rbrt.fr/glean/internal/feed"
21+ "pkg.rbrt.fr/glean/internal/feedback"
20 "pkg.rbrt.fr/glean/internal/server"22 "pkg.rbrt.fr/glean/internal/server"
21 23
22 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"24 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
@@ -73,9 +75,9 @@ func main() {
73 siteFetcher := atproto.NewStandardSiteFetcher(logger)75 siteFetcher := atproto.NewStandardSiteFetcher(logger)
74 scheduler := feed.NewScheduler(storeAdapter, siteFetcher, logger, *fetchInterval, 30*time.Minute)76 scheduler := feed.NewScheduler(storeAdapter, siteFetcher, logger, *fetchInterval, 30*time.Minute)
75 77
76- var embedder cluster.Embedder78+ var embedder ai.Embedder
77 if embedURL := envOr("GLEAN_EMBED_BASE_URL", ""); embedURL != "" {79 if embedURL := envOr("GLEAN_EMBED_BASE_URL", ""); embedURL != "" {
78- embedder = cluster.NewEmbedderClient(cluster.EmbedderClientConfig{80+ embedder = ai.NewEmbedder(ai.EmbedConfig{
79 BaseURL: embedURL,81 BaseURL: embedURL,
80 APIKey: envOr("GLEAN_EMBED_API_KEY", ""),82 APIKey: envOr("GLEAN_EMBED_API_KEY", ""),
81 Model: envOr("GLEAN_EMBED_MODEL", "text-embedding-3-small"),83 Model: envOr("GLEAN_EMBED_MODEL", "text-embedding-3-small"),
@@ -90,16 +92,16 @@ func main() {
90 }92 }
91 }93 }
92 94
93- var llm *cluster.LLMClient95+ var llm ai.TextModel
94 if llmURL := envOr("GLEAN_LLM_BASE_URL", ""); llmURL != "" {96 if llmURL := envOr("GLEAN_LLM_BASE_URL", ""); llmURL != "" {
95- llm = cluster.NewLLMClient(cluster.LLMClientConfig{97+ llm = ai.NewLLM(ai.LLMConfig{
96 BaseURL: llmURL,98 BaseURL: llmURL,
97 APIKey: envOr("GLEAN_LLM_API_KEY", ""),99 APIKey: envOr("GLEAN_LLM_API_KEY", ""),
98 Model: envOr("GLEAN_LLM_MODEL", "gpt-4o-mini"),100 Model: envOr("GLEAN_LLM_MODEL", "gpt-4o-mini"),
99 })101 })
100 }102 }
101 103
102- engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, logger, *clusterInterval, cluster.DefaultConfig())104+ engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())
103 105
104 fetcher := feed.NewFetcher(siteFetcher)106 fetcher := feed.NewFetcher(siteFetcher)
105 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))