nandi/gleanpublic Fork 0
9cacbcf
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.

Switch to precomputed recommendations with on-demand fallbackUnverified

Julien Robert committed 2026-06-05T23:05:42+02:00 Browse files
9cacbcf parent: 7b342c0
modified docs/specs.md +17 -6
@@ -701,9 +701,9 @@ J(U1, U2) = jaccard_subscriptions + 0.3 * jaccard_likes + 0.2 * jaccard_tags + 0
701701
702702 Like overlap uses exponential time decay: `EXP(-0.023 * age_days)` (30-day half-life).
703703
704-### 7.4 On-Demand Scoring
704+### 7.4 Precomputed + On-Demand Scoring
705705
706-Recommendations are computed **on-demand** at query time, not pre-materialized. This avoids write amplification on every cron run.
706+Recommendations are **precomputed** for all active users on every cron cycle (`GLEAN_CLUSTER_INTERVAL`, default 1h) and stored in the `precomputed_recommendations` table
707707
708708 **Feed recommendation score** (computed in SQL):
709709
@@ -795,8 +795,9 @@ A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`
795795 6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
796796 7. **Compute signal profiles**: Per-user category/tag/like summaries
797797 8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
798-9. **Prune old impressions**: Delete `recommendation_impressions` older than 90 days
798+9. **Precompute recommendations**: For each active user, compute feed, article, and people recommendations and store as JSON in the `precomputed_recommendations` table. This ensures instant load times for all recommendation sections.
799799 10. **DB maintenance**: Run `PRAGMA incremental_vacuum` on all 3 databases (users, articles, recs) to reclaim freed pages. Incremental auto-vacuum is enabled via `PRAGMA auto_vacuum = INCREMENTAL` at connection time, so pages freed by impression pruning and other deletions are reclaimed each cycle.
800+11. **Prune old impressions**: Delete `recommendation_impressions` older than 90 days
800801
801802 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
802803
@@ -828,7 +829,7 @@ CREATE TABLE recommendation_impressions (
828829
829830 ### 7.12 Computed Recommendation Tables (`<base>_recs`)
830831
831-Written exclusively by the cron. No user-facing writes — only reads during on-demand scoring.
832+Written exclusively by the cron. Read during recommendation requests (precomputed results served first, on-demand fallback if missing).
832833
833834 ```sql
834835 CREATE TABLE feed_similarity (
@@ -879,6 +880,14 @@ CREATE TABLE user_signal_profiles (
879880 top_tags TEXT,
880881 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
881882 );
883+
884+CREATE TABLE precomputed_recommendations (
885+ user_did TEXT NOT NULL,
886+ rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')),
887+ data TEXT NOT NULL,
888+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
889+ PRIMARY KEY (user_did, rec_type)
890+);
882891 ```
883892
884893 ### 7.13 Embeddings (recommended)
@@ -1028,7 +1037,8 @@ glean/
10281037 │ ├── cluster/
10291038 │ │ ├── jaccard.go # Jaccard similarity computation
10301039 │ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost, language detection
1031-│ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)
1040+│ │ ├── scoring.go # Feed + people + article recommendation queries (precomputed + on-demand fallback)
1041+│ │ ├── precompute.go # Precompute all user recommendations (run by cron 4x/day)
10321042 │ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
10331043 │ │ ├── weights.go # Bandit-style signal weight auto-tuning
10341044 │ │ ├── diversity.go # Post-query domain/category diversity filtering
@@ -1137,7 +1147,8 @@ Cron (every 1h) ──► Cluster Engine
11371147
11381148 Browser ──GET /dashboard──► Server
11391149
1140- ├─► Compute recommendations on-demand (filtered by user's language preferences)
1150+ ├─► Read precomputed recommendations from DB (instant)
1151+ │ (fallback to on-demand if missing)
11411152 ├─► Fetch feed metadata
11421153 └─◄ Render recommendation cards (htmx)
11431154 ```
@@ -701,9 +701,9 @@ J(U1, U2) = jaccard_subscriptions + 0.3 * jaccard_likes + 0.2 * jaccard_tags + 0
701 701
702 Like overlap uses exponential time decay: `EXP(-0.023 * age_days)` (30-day half-life).702 Like overlap uses exponential time decay: `EXP(-0.023 * age_days)` (30-day half-life).
703 703
704-### 7.4 On-Demand Scoring704+### 7.4 Precomputed + On-Demand Scoring
705 705
706-Recommendations are computed **on-demand** at query time, not pre-materialized. This avoids write amplification on every cron run.706+Recommendations are **precomputed** for all active users on every cron cycle (`GLEAN_CLUSTER_INTERVAL`, default 1h) and stored in the `precomputed_recommendations` table
707 707
708 **Feed recommendation score** (computed in SQL):708 **Feed recommendation score** (computed in SQL):
709 709
@@ -795,8 +795,9 @@ A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`
795 6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)795 6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
796 7. **Compute signal profiles**: Per-user category/tag/like summaries796 7. **Compute signal profiles**: Per-user category/tag/like summaries
797 8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action797 8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
798-9. **Prune old impressions**: Delete `recommendation_impressions` older than 90 days798+9. **Precompute recommendations**: For each active user, compute feed, article, and people recommendations and store as JSON in the `precomputed_recommendations` table. This ensures instant load times for all recommendation sections.
799 10. **DB maintenance**: Run `PRAGMA incremental_vacuum` on all 3 databases (users, articles, recs) to reclaim freed pages. Incremental auto-vacuum is enabled via `PRAGMA auto_vacuum = INCREMENTAL` at connection time, so pages freed by impression pruning and other deletions are reclaimed each cycle.799 10. **DB maintenance**: Run `PRAGMA incremental_vacuum` on all 3 databases (users, articles, recs) to reclaim freed pages. Incremental auto-vacuum is enabled via `PRAGMA auto_vacuum = INCREMENTAL` at connection time, so pages freed by impression pruning and other deletions are reclaimed each cycle.
800+11. **Prune old impressions**: Delete `recommendation_impressions` older than 90 days
800 801
801 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.802 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
802 803
@@ -828,7 +829,7 @@ CREATE TABLE recommendation_impressions (
828 829
829 ### 7.12 Computed Recommendation Tables (`<base>_recs`)830 ### 7.12 Computed Recommendation Tables (`<base>_recs`)
830 831
831-Written exclusively by the cron. No user-facing writes — only reads during on-demand scoring.832+Written exclusively by the cron. Read during recommendation requests (precomputed results served first, on-demand fallback if missing).
832 833
833 ```sql834 ```sql
834 CREATE TABLE feed_similarity (835 CREATE TABLE feed_similarity (
@@ -879,6 +880,14 @@ CREATE TABLE user_signal_profiles (
879 top_tags TEXT,880 top_tags TEXT,
880 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP881 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
881 );882 );
883+
884+CREATE TABLE precomputed_recommendations (
885+ user_did TEXT NOT NULL,
886+ rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')),
887+ data TEXT NOT NULL,
888+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
889+ PRIMARY KEY (user_did, rec_type)
890+);
882 ```891 ```
883 892
884 ### 7.13 Embeddings (recommended)893 ### 7.13 Embeddings (recommended)
@@ -1028,7 +1037,8 @@ glean/
1028 │ ├── cluster/1037 │ ├── cluster/
1029 │ │ ├── jaccard.go # Jaccard similarity computation1038 │ │ ├── jaccard.go # Jaccard similarity computation
1030 │ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost, language detection1039 │ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost, language detection
1031-│ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)1040+│ │ ├── scoring.go # Feed + people + article recommendation queries (precomputed + on-demand fallback)
1041+│ │ ├── precompute.go # Precompute all user recommendations (run by cron 4x/day)
1032 │ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)1042 │ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
1033 │ │ ├── weights.go # Bandit-style signal weight auto-tuning1043 │ │ ├── weights.go # Bandit-style signal weight auto-tuning
1034 │ │ ├── diversity.go # Post-query domain/category diversity filtering1044 │ │ ├── diversity.go # Post-query domain/category diversity filtering
@@ -1137,7 +1147,8 @@ Cron (every 1h) ──► Cluster Engine
1137 1147
1138 Browser ──GET /dashboard──► Server1148 Browser ──GET /dashboard──► Server
1139 1149
1140- ├─► Compute recommendations on-demand (filtered by user's language preferences)1150+ ├─► Read precomputed recommendations from DB (instant)
1151+ │ (fallback to on-demand if missing)
1141 ├─► Fetch feed metadata1152 ├─► Fetch feed metadata
1142 └─◄ Render recommendation cards (htmx)1153 └─◄ Render recommendation cards (htmx)
1143 ```1154 ```
modified go.mod +1 -1
@@ -8,7 +8,6 @@ require (
88 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28
99 github.com/go-chi/chi/v5 v5.2.5
1010 github.com/go-chi/cors v1.2.2
11- github.com/hashicorp/golang-lru/v2 v2.0.7
1211 github.com/mattn/go-sqlite3 v1.14.22
1312 github.com/openai/openai-go v1.12.0
1413 github.com/prometheus/client_golang v1.19.1
@@ -28,6 +27,7 @@ require (
2827 github.com/google/go-cmp v0.6.0 // indirect
2928 github.com/google/go-querystring v1.1.0 // indirect
3029 github.com/gorilla/websocket v1.5.3 // indirect
30+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
3131 github.com/ipfs/go-cid v0.4.1 // indirect
3232 github.com/klauspost/compress v1.17.9 // indirect
3333 github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@@ -8,7 +8,6 @@ require (
8 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda288 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28
9 github.com/go-chi/chi/v5 v5.2.59 github.com/go-chi/chi/v5 v5.2.5
10 github.com/go-chi/cors v1.2.210 github.com/go-chi/cors v1.2.2
11- github.com/hashicorp/golang-lru/v2 v2.0.7
12 github.com/mattn/go-sqlite3 v1.14.2211 github.com/mattn/go-sqlite3 v1.14.22
13 github.com/openai/openai-go v1.12.012 github.com/openai/openai-go v1.12.0
14 github.com/prometheus/client_golang v1.19.113 github.com/prometheus/client_golang v1.19.1
@@ -28,6 +27,7 @@ require (
28 github.com/google/go-cmp v0.6.0 // indirect27 github.com/google/go-cmp v0.6.0 // indirect
29 github.com/google/go-querystring v1.1.0 // indirect28 github.com/google/go-querystring v1.1.0 // indirect
30 github.com/gorilla/websocket v1.5.3 // indirect29 github.com/gorilla/websocket v1.5.3 // indirect
30+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
31 github.com/ipfs/go-cid v0.4.1 // indirect31 github.com/ipfs/go-cid v0.4.1 // indirect
32 github.com/klauspost/compress v1.17.9 // indirect32 github.com/klauspost/compress v1.17.9 // indirect
33 github.com/klauspost/cpuid/v2 v2.2.7 // indirect33 github.com/klauspost/cpuid/v2 v2.2.7 // indirect
modified internal/cluster/cron.go +6 -7
@@ -10,7 +10,8 @@ import (
1010 )
1111
1212 // Cron periodically runs all cluster engine computations (similarity, embeddings,
13-// follow distances, signal profiles, auto-dismiss) on a fixed interval.
13+// follow distances, signal profiles, auto-dismiss, recommendation precomputation)
14+// on a fixed interval.
1415 type Cron struct {
1516 engine *Engine
1617 interval time.Duration
@@ -39,12 +40,6 @@ func (c *Cron) Run(ctx context.Context) error {
3940 if !c.engine.mu.TryLock() {
4041 c.logger.Info("skipping computation: already in progress")
4142 } else {
42- c.engine.feedCache.Purge()
43- c.engine.peopleCache.Purge()
44- c.engine.articleCache.Purge()
45- c.engine.globalTrendingCache.Purge()
46- c.engine.personalTrendingCache.Purge()
47-
4843 if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {
4944 c.engine.logger.Error("feed embeddings failed", "error", err)
5045 }
@@ -69,6 +64,10 @@ func (c *Cron) Run(ctx context.Context) error {
6964 if err := c.engine.feedback.AutoDismissStale(ctx, 5, 5); err != nil {
7065 c.engine.logger.Error("auto dismiss failed", "error", err)
7166 }
67+ if err := c.engine.PrecomputeAllRecommendations(ctx); err != nil {
68+ c.engine.logger.Error("recommendation precomputation failed", "error", err)
69+ }
70+
7271 c.engine.mu.Unlock()
7372 }
7473
@@ -10,7 +10,8 @@ import (
10 )10 )
11 11
12 // Cron periodically runs all cluster engine computations (similarity, embeddings,12 // Cron periodically runs all cluster engine computations (similarity, embeddings,
13-// follow distances, signal profiles, auto-dismiss) on a fixed interval.13+// follow distances, signal profiles, auto-dismiss, recommendation precomputation)
14+// on a fixed interval.
14 type Cron struct {15 type Cron struct {
15 engine *Engine16 engine *Engine
16 interval time.Duration17 interval time.Duration
@@ -39,12 +40,6 @@ func (c *Cron) Run(ctx context.Context) error {
39 if !c.engine.mu.TryLock() {40 if !c.engine.mu.TryLock() {
40 c.logger.Info("skipping computation: already in progress")41 c.logger.Info("skipping computation: already in progress")
41 } else {42 } else {
42- c.engine.feedCache.Purge()
43- c.engine.peopleCache.Purge()
44- c.engine.articleCache.Purge()
45- c.engine.globalTrendingCache.Purge()
46- c.engine.personalTrendingCache.Purge()
47-
48 if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {43 if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {
49 c.engine.logger.Error("feed embeddings failed", "error", err)44 c.engine.logger.Error("feed embeddings failed", "error", err)
50 }45 }
@@ -69,6 +64,10 @@ func (c *Cron) Run(ctx context.Context) error {
69 if err := c.engine.feedback.AutoDismissStale(ctx, 5, 5); err != nil {64 if err := c.engine.feedback.AutoDismissStale(ctx, 5, 5); err != nil {
70 c.engine.logger.Error("auto dismiss failed", "error", err)65 c.engine.logger.Error("auto dismiss failed", "error", err)
71 }66 }
67+ if err := c.engine.PrecomputeAllRecommendations(ctx); err != nil {
68+ c.engine.logger.Error("recommendation precomputation failed", "error", err)
69+ }
70+
72 c.engine.mu.Unlock()71 c.engine.mu.Unlock()
73 }72 }
74 73
modified internal/cluster/jaccard.go +8 -25
@@ -6,9 +6,6 @@ import (
66 "fmt"
77 "log/slog"
88 "sync"
9- "time"
10-
11- "github.com/hashicorp/golang-lru/v2/expirable"
129
1310 "pkg.rbrt.fr/glean/internal/db"
1411 "pkg.rbrt.fr/glean/internal/feedback"
@@ -49,31 +46,17 @@ type Engine struct {
4946 embedder ml.Embedder
5047 llm ml.TextModel
5148 feedback *feedback.Service
52-
53- feedCache *expirable.LRU[string, []*FeedRecommendation]
54- peopleCache *expirable.LRU[string, []*PersonRecommendation]
55- articleCache *expirable.LRU[string, []*ArticleRecommendation]
56- globalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
57- personalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
5849 }
5950
60-// recCacheSize is the maximum number of recommendations to cache per user.
61-const recCacheSize = 512
62-
63-func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder ml.Embedder, llm ml.TextModel, fb *feedback.Service, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
51+func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder ml.Embedder, llm ml.TextModel, fb *feedback.Service, logger *slog.Logger, config Config) *Engine {
6452 return &Engine{
65- db: sqlDB,
66- articles: articles,
67- logger: logger,
68- config: config,
69- embedder: embedder,
70- llm: llm,
71- feedback: fb,
72- feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),
73- peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),
74- articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),
75- globalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
76- personalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
53+ db: sqlDB,
54+ articles: articles,
55+ logger: logger,
56+ config: config,
57+ embedder: embedder,
58+ llm: llm,
59+ feedback: fb,
7760 }
7861 }
7962
@@ -6,9 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "log/slog"7 "log/slog"
8 "sync"8 "sync"
9- "time"
10-
11- "github.com/hashicorp/golang-lru/v2/expirable"
12 9
13 "pkg.rbrt.fr/glean/internal/db"10 "pkg.rbrt.fr/glean/internal/db"
14 "pkg.rbrt.fr/glean/internal/feedback"11 "pkg.rbrt.fr/glean/internal/feedback"
@@ -49,31 +46,17 @@ type Engine struct {
49 embedder ml.Embedder46 embedder ml.Embedder
50 llm ml.TextModel47 llm ml.TextModel
51 feedback *feedback.Service48 feedback *feedback.Service
52-
53- feedCache *expirable.LRU[string, []*FeedRecommendation]
54- peopleCache *expirable.LRU[string, []*PersonRecommendation]
55- articleCache *expirable.LRU[string, []*ArticleRecommendation]
56- globalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
57- personalTrendingCache *expirable.LRU[string, []*db.TrendingItem]
58 }49 }
59 50
60-// recCacheSize is the maximum number of recommendations to cache per user.51+func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder ml.Embedder, llm ml.TextModel, fb *feedback.Service, logger *slog.Logger, config Config) *Engine {
61-const recCacheSize = 512
62-
63-func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder ml.Embedder, llm ml.TextModel, fb *feedback.Service, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine {
64 return &Engine{52 return &Engine{
65- db: sqlDB,53+ db: sqlDB,
66- articles: articles,54+ articles: articles,
67- logger: logger,55+ logger: logger,
68- config: config,56+ config: config,
69- embedder: embedder,57+ embedder: embedder,
70- llm: llm,58+ llm: llm,
71- feedback: fb,59+ feedback: fb,
72- feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL),
73- peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL),
74- articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL),
75- globalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
76- personalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL),
77 }60 }
78 }61 }
79 62
modified internal/cluster/jaccard_test.go +1 -2
@@ -8,7 +8,6 @@ import (
88 "os"
99 "strings"
1010 "testing"
11- "time"
1211
1312 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
1413 "pkg.rbrt.fr/glean/internal/db"
@@ -96,7 +95,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
9695 }
9796
9897 func newTestEngine(dbs *db.Store) *Engine {
99- return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, feedback.NewService(dbs.SQLDB()), slog.Default(), time.Hour, DefaultConfig())
98+ return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, feedback.NewService(dbs.SQLDB()), slog.Default(), DefaultConfig())
10099 }
101100
102101 func TestComputeFeedSimilarity(t *testing.T) {
@@ -8,7 +8,6 @@ import (
8 "os"8 "os"
9 "strings"9 "strings"
10 "testing"10 "testing"
11- "time"
12 11
13 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"12 vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
14 "pkg.rbrt.fr/glean/internal/db"13 "pkg.rbrt.fr/glean/internal/db"
@@ -96,7 +95,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
96 }95 }
97 96
98 func newTestEngine(dbs *db.Store) *Engine {97 func newTestEngine(dbs *db.Store) *Engine {
99- return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, feedback.NewService(dbs.SQLDB()), slog.Default(), time.Hour, DefaultConfig())98+ return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, feedback.NewService(dbs.SQLDB()), slog.Default(), DefaultConfig())
100 }99 }
101 100
102 func TestComputeFeedSimilarity(t *testing.T) {101 func TestComputeFeedSimilarity(t *testing.T) {
added internal/cluster/precompute.go +157 -0
new file mode 100644
@@ -0,0 +1,157 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "time"
7+)
8+
9+type PrecomputedRec struct {
10+ RecType string
11+ Data string
12+}
13+
14+func (e *Engine) PrecomputeAllRecommendations(ctx context.Context) error {
15+ e.logger.Info("starting recommendation precomputation")
16+ start := time.Now()
17+
18+ users, err := e.listActiveUsers(ctx)
19+ if err != nil {
20+ return err
21+ }
22+
23+ computed := 0
24+ for _, did := range users {
25+ select {
26+ case <-ctx.Done():
27+ return ctx.Err()
28+ default:
29+ }
30+
31+ if err := e.precomputeForUser(ctx, did); err != nil {
32+ e.logger.Warn("precompute failed for user", "did", did, "error", err)
33+ continue
34+ }
35+ computed++
36+ }
37+
38+ e.logger.Info("recommendation precomputation complete",
39+ "users", computed,
40+ "duration", time.Since(start),
41+ )
42+ return nil
43+}
44+
45+func (e *Engine) precomputeForUser(ctx context.Context, userDID string) error {
46+ feedRecs, err := e.ComputeFeedRecommendationsOnDemand(ctx, userDID, 10)
47+ if err != nil {
48+ return err
49+ }
50+ if len(feedRecs) > 0 {
51+ normalizeFeedScores(feedRecs)
52+ feedRecs = ApplyDiversity(feedRecs, 5)
53+ }
54+
55+ articleRecs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, nil, 10)
56+ if err != nil {
57+ return err
58+ }
59+ if len(articleRecs) > 0 {
60+ normalizeArticleScores(articleRecs)
61+ if len(articleRecs) > 5 {
62+ articleRecs = articleRecs[:5]
63+ }
64+ }
65+
66+ half := 3
67+ inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, half)
68+ if err != nil {
69+ return err
70+ }
71+ outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, half)
72+ if err != nil {
73+ return err
74+ }
75+ peopleRecs := append(inNet, outNet...)
76+ if len(peopleRecs) > 0 {
77+ normalizePersonScores(peopleRecs)
78+ if len(peopleRecs) > 6 {
79+ peopleRecs = peopleRecs[:6]
80+ }
81+ }
82+
83+ for _, rec := range []PrecomputedRec{
84+ {RecType: "feed", Data: mustJSON(feedRecs)},
85+ {RecType: "article", Data: mustJSON(articleRecs)},
86+ {RecType: "person", Data: mustJSON(peopleRecs)},
87+ } {
88+ if rec.Data == "null" {
89+ continue
90+ }
91+ if _, err := e.db.ExecContext(ctx, `
92+ INSERT INTO recs.precomputed_recommendations (user_did, rec_type, data, computed_at)
93+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
94+ ON CONFLICT(user_did, rec_type) DO UPDATE SET data = excluded.data, computed_at = excluded.computed_at
95+ `, userDID, rec.RecType, rec.Data); err != nil {
96+ e.logger.Warn("failed to store precomputed rec", "did", userDID, "type", rec.RecType, "error", err)
97+ }
98+ }
99+
100+ return nil
101+}
102+
103+func (e *Engine) listActiveUsers(ctx context.Context) ([]string, error) {
104+ rows, err := e.db.QueryContext(ctx, `
105+ SELECT DISTINCT s.user_did FROM articles.subscriptions s
106+ UNION
107+ SELECT DISTINCT author_did FROM articles.likes
108+ `)
109+ if err != nil {
110+ return nil, err
111+ }
112+ defer rows.Close()
113+
114+ var dids []string
115+ for rows.Next() {
116+ var did string
117+ if err := rows.Scan(&did); err != nil {
118+ return nil, err
119+ }
120+ dids = append(dids, did)
121+ }
122+ return dids, rows.Err()
123+}
124+
125+func (e *Engine) getPrecomputed(ctx context.Context, userDID, recType string) (string, bool) {
126+ var data string
127+ err := e.db.QueryRowContext(ctx, `
128+ SELECT data FROM recs.precomputed_recommendations
129+ WHERE user_did = ? AND rec_type = ?
130+ `, userDID, recType).Scan(&data)
131+ if err != nil {
132+ return "", false
133+ }
134+ return data, true
135+}
136+
137+func (e *Engine) invalidatePrecomputed(ctx context.Context, userDID, recType string) {
138+ _, _ = e.db.ExecContext(ctx, `
139+ DELETE FROM recs.precomputed_recommendations
140+ WHERE user_did = ? AND rec_type = ?
141+ `, userDID, recType)
142+}
143+
144+func (e *Engine) recomputeForUser(ctx context.Context, userDID, recType string) {
145+ e.invalidatePrecomputed(ctx, userDID, recType)
146+ if err := e.precomputeForUser(ctx, userDID); err != nil {
147+ e.logger.Warn("recompute failed for user", "did", userDID, "type", recType, "error", err)
148+ }
149+}
150+
151+func mustJSON(v any) string {
152+ b, err := json.Marshal(v)
153+ if err != nil {
154+ return "null"
155+ }
156+ return string(b)
157+}
new file mode 100644
@@ -0,0 +1,157 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "time"
7+)
8+
9+type PrecomputedRec struct {
10+ RecType string
11+ Data string
12+}
13+
14+func (e *Engine) PrecomputeAllRecommendations(ctx context.Context) error {
15+ e.logger.Info("starting recommendation precomputation")
16+ start := time.Now()
17+
18+ users, err := e.listActiveUsers(ctx)
19+ if err != nil {
20+ return err
21+ }
22+
23+ computed := 0
24+ for _, did := range users {
25+ select {
26+ case <-ctx.Done():
27+ return ctx.Err()
28+ default:
29+ }
30+
31+ if err := e.precomputeForUser(ctx, did); err != nil {
32+ e.logger.Warn("precompute failed for user", "did", did, "error", err)
33+ continue
34+ }
35+ computed++
36+ }
37+
38+ e.logger.Info("recommendation precomputation complete",
39+ "users", computed,
40+ "duration", time.Since(start),
41+ )
42+ return nil
43+}
44+
45+func (e *Engine) precomputeForUser(ctx context.Context, userDID string) error {
46+ feedRecs, err := e.ComputeFeedRecommendationsOnDemand(ctx, userDID, 10)
47+ if err != nil {
48+ return err
49+ }
50+ if len(feedRecs) > 0 {
51+ normalizeFeedScores(feedRecs)
52+ feedRecs = ApplyDiversity(feedRecs, 5)
53+ }
54+
55+ articleRecs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, nil, 10)
56+ if err != nil {
57+ return err
58+ }
59+ if len(articleRecs) > 0 {
60+ normalizeArticleScores(articleRecs)
61+ if len(articleRecs) > 5 {
62+ articleRecs = articleRecs[:5]
63+ }
64+ }
65+
66+ half := 3
67+ inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, half)
68+ if err != nil {
69+ return err
70+ }
71+ outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, half)
72+ if err != nil {
73+ return err
74+ }
75+ peopleRecs := append(inNet, outNet...)
76+ if len(peopleRecs) > 0 {
77+ normalizePersonScores(peopleRecs)
78+ if len(peopleRecs) > 6 {
79+ peopleRecs = peopleRecs[:6]
80+ }
81+ }
82+
83+ for _, rec := range []PrecomputedRec{
84+ {RecType: "feed", Data: mustJSON(feedRecs)},
85+ {RecType: "article", Data: mustJSON(articleRecs)},
86+ {RecType: "person", Data: mustJSON(peopleRecs)},
87+ } {
88+ if rec.Data == "null" {
89+ continue
90+ }
91+ if _, err := e.db.ExecContext(ctx, `
92+ INSERT INTO recs.precomputed_recommendations (user_did, rec_type, data, computed_at)
93+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
94+ ON CONFLICT(user_did, rec_type) DO UPDATE SET data = excluded.data, computed_at = excluded.computed_at
95+ `, userDID, rec.RecType, rec.Data); err != nil {
96+ e.logger.Warn("failed to store precomputed rec", "did", userDID, "type", rec.RecType, "error", err)
97+ }
98+ }
99+
100+ return nil
101+}
102+
103+func (e *Engine) listActiveUsers(ctx context.Context) ([]string, error) {
104+ rows, err := e.db.QueryContext(ctx, `
105+ SELECT DISTINCT s.user_did FROM articles.subscriptions s
106+ UNION
107+ SELECT DISTINCT author_did FROM articles.likes
108+ `)
109+ if err != nil {
110+ return nil, err
111+ }
112+ defer rows.Close()
113+
114+ var dids []string
115+ for rows.Next() {
116+ var did string
117+ if err := rows.Scan(&did); err != nil {
118+ return nil, err
119+ }
120+ dids = append(dids, did)
121+ }
122+ return dids, rows.Err()
123+}
124+
125+func (e *Engine) getPrecomputed(ctx context.Context, userDID, recType string) (string, bool) {
126+ var data string
127+ err := e.db.QueryRowContext(ctx, `
128+ SELECT data FROM recs.precomputed_recommendations
129+ WHERE user_did = ? AND rec_type = ?
130+ `, userDID, recType).Scan(&data)
131+ if err != nil {
132+ return "", false
133+ }
134+ return data, true
135+}
136+
137+func (e *Engine) invalidatePrecomputed(ctx context.Context, userDID, recType string) {
138+ _, _ = e.db.ExecContext(ctx, `
139+ DELETE FROM recs.precomputed_recommendations
140+ WHERE user_did = ? AND rec_type = ?
141+ `, userDID, recType)
142+}
143+
144+func (e *Engine) recomputeForUser(ctx context.Context, userDID, recType string) {
145+ e.invalidatePrecomputed(ctx, userDID, recType)
146+ if err := e.precomputeForUser(ctx, userDID); err != nil {
147+ e.logger.Warn("recompute failed for user", "did", userDID, "type", recType, "error", err)
148+ }
149+}
150+
151+func mustJSON(v any) string {
152+ b, err := json.Marshal(v)
153+ if err != nil {
154+ return "null"
155+ }
156+ return string(b)
157+}
modified internal/cluster/scoring.go +22 -47
@@ -3,8 +3,8 @@ package cluster
33 import (
44 "context"
55 "database/sql"
6+ "encoding/json"
67 "fmt"
7- "strings"
88 "time"
99
1010 "pkg.rbrt.fr/glean/internal/db"
@@ -47,15 +47,15 @@ type ArticleRecommendation struct {
4747 }
4848
4949 func (e *Engine) InvalidateFeedCache(userDID string) {
50- e.feedCache.Remove(userDID)
50+ go e.recomputeForUser(context.Background(), userDID, "feed")
5151 }
5252
5353 func (e *Engine) InvalidateArticleCache(userDID string) {
54- e.articleCache.Remove(userDID)
54+ go e.recomputeForUser(context.Background(), userDID, "article")
5555 }
5656
5757 func (e *Engine) InvalidatePeopleCache(userDID string) {
58- e.peopleCache.Remove(userDID)
58+ go e.recomputeForUser(context.Background(), userDID, "person")
5959 }
6060
6161 // GetFeedRecommendations returns feed recommendations for a user. Users with
@@ -63,8 +63,11 @@ func (e *Engine) InvalidatePeopleCache(userDID string) {
6363 // KNN or graph+popular fallback). Results are min-max normalized and
6464 // diversity-filtered before returning.
6565 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
66- if entry, ok := e.feedCache.Get(userDID); ok {
67- return entry, nil
66+ if data, ok := e.getPrecomputed(ctx, userDID, "feed"); ok {
67+ var recs []*FeedRecommendation
68+ if err := json.Unmarshal([]byte(data), &recs); err == nil {
69+ return recs, nil
70+ }
6871 }
6972
7073 subCount := 0
@@ -84,9 +87,7 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
8487 }
8588
8689 normalizeFeedScores(recs)
87- result := ApplyDiversity(recs, limit)
88- e.feedCache.Add(userDID, result)
89- return result, nil
90+ return ApplyDiversity(recs, limit), nil
9091 }
9192
9293 // GetPeopleRecommendations returns similar users based on subscription overlap,
@@ -94,8 +95,11 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
9495 // limit come from the user's network (followed) and half from outside. When
9596 // outside-network candidates are scarce, in-network fills the remaining slots.
9697 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
97- if entry, ok := e.peopleCache.Get(userDID); ok {
98- return entry, nil
98+ if data, ok := e.getPrecomputed(ctx, userDID, "person"); ok {
99+ var recs []*PersonRecommendation
100+ if err := json.Unmarshal([]byte(data), &recs); err == nil {
101+ return recs, nil
102+ }
99103 }
100104
101105 half := max(limit/2, 1)
@@ -115,7 +119,6 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
115119 recs = append(recs, outNet...)
116120
117121 normalizePersonScores(recs)
118- e.peopleCache.Add(userDID, recs)
119122 return recs, nil
120123 }
121124
@@ -124,9 +127,11 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
124127 // (embedding KNN against user's liked articles), and recency. Scores are
125128 // min-max normalized.
126129 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
127- artKey := userDID + "|" + strings.Join(languages, ",")
128- if entry, ok := e.articleCache.Get(artKey); ok {
129- return entry, nil
130+ if data, ok := e.getPrecomputed(ctx, userDID, "article"); ok {
131+ var recs []*ArticleRecommendation
132+ if err := json.Unmarshal([]byte(data), &recs); err == nil {
133+ return recs, nil
134+ }
130135 }
131136
132137 recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)
@@ -134,47 +139,17 @@ func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string,
134139 return nil, err
135140 }
136141 normalizeArticleScores(recs)
137- e.articleCache.Add(artKey, recs)
138142 return recs, nil
139143 }
140144
141145 func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) {
142- if offset == 0 {
143- if entry, ok := e.globalTrendingCache.Get(userDID); ok {
144- return entry, nil
145- }
146- }
147-
148146 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
149- items, err := e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset)
150- if err != nil {
151- return nil, err
152- }
153-
154- if offset == 0 {
155- e.globalTrendingCache.Add(userDID, items)
156- }
157- return items, nil
147+ return e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset)
158148 }
159149
160150 func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) {
161- key := userDID + "|" + strings.Join(languages, ",")
162- if offset == 0 {
163- if entry, ok := e.personalTrendingCache.Get(key); ok {
164- return entry, nil
165- }
166- }
167-
168151 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
169- items, err := e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset)
170- if err != nil {
171- return nil, err
172- }
173-
174- if offset == 0 {
175- e.personalTrendingCache.Add(key, items)
176- }
177- return items, nil
152+ return e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset)
178153 }
179154
180155 // SignalWeights holds per-signal multipliers used in the recommendation scoring
@@ -3,8 +3,8 @@ package cluster
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6+ "encoding/json"
6 "fmt"7 "fmt"
7- "strings"
8 "time"8 "time"
9 9
10 "pkg.rbrt.fr/glean/internal/db"10 "pkg.rbrt.fr/glean/internal/db"
@@ -47,15 +47,15 @@ type ArticleRecommendation struct {
47 }47 }
48 48
49 func (e *Engine) InvalidateFeedCache(userDID string) {49 func (e *Engine) InvalidateFeedCache(userDID string) {
50- e.feedCache.Remove(userDID)50+ go e.recomputeForUser(context.Background(), userDID, "feed")
51 }51 }
52 52
53 func (e *Engine) InvalidateArticleCache(userDID string) {53 func (e *Engine) InvalidateArticleCache(userDID string) {
54- e.articleCache.Remove(userDID)54+ go e.recomputeForUser(context.Background(), userDID, "article")
55 }55 }
56 56
57 func (e *Engine) InvalidatePeopleCache(userDID string) {57 func (e *Engine) InvalidatePeopleCache(userDID string) {
58- e.peopleCache.Remove(userDID)58+ go e.recomputeForUser(context.Background(), userDID, "person")
59 }59 }
60 60
61 // GetFeedRecommendations returns feed recommendations for a user. Users with61 // GetFeedRecommendations returns feed recommendations for a user. Users with
@@ -63,8 +63,11 @@ func (e *Engine) InvalidatePeopleCache(userDID string) {
63 // KNN or graph+popular fallback). Results are min-max normalized and63 // KNN or graph+popular fallback). Results are min-max normalized and
64 // diversity-filtered before returning.64 // diversity-filtered before returning.
65 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {65 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
66- if entry, ok := e.feedCache.Get(userDID); ok {66+ if data, ok := e.getPrecomputed(ctx, userDID, "feed"); ok {
67- return entry, nil67+ var recs []*FeedRecommendation
68+ if err := json.Unmarshal([]byte(data), &recs); err == nil {
69+ return recs, nil
70+ }
68 }71 }
69 72
70 subCount := 073 subCount := 0
@@ -84,9 +87,7 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
84 }87 }
85 88
86 normalizeFeedScores(recs)89 normalizeFeedScores(recs)
87- result := ApplyDiversity(recs, limit)90+ return ApplyDiversity(recs, limit), nil
88- e.feedCache.Add(userDID, result)
89- return result, nil
90 }91 }
91 92
92 // GetPeopleRecommendations returns similar users based on subscription overlap,93 // GetPeopleRecommendations returns similar users based on subscription overlap,
@@ -94,8 +95,11 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
94 // limit come from the user's network (followed) and half from outside. When95 // limit come from the user's network (followed) and half from outside. When
95 // outside-network candidates are scarce, in-network fills the remaining slots.96 // outside-network candidates are scarce, in-network fills the remaining slots.
96 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {97 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
97- if entry, ok := e.peopleCache.Get(userDID); ok {98+ if data, ok := e.getPrecomputed(ctx, userDID, "person"); ok {
98- return entry, nil99+ var recs []*PersonRecommendation
100+ if err := json.Unmarshal([]byte(data), &recs); err == nil {
101+ return recs, nil
102+ }
99 }103 }
100 104
101 half := max(limit/2, 1)105 half := max(limit/2, 1)
@@ -115,7 +119,6 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
115 recs = append(recs, outNet...)119 recs = append(recs, outNet...)
116 120
117 normalizePersonScores(recs)121 normalizePersonScores(recs)
118- e.peopleCache.Add(userDID, recs)
119 return recs, nil122 return recs, nil
120 }123 }
121 124
@@ -124,9 +127,11 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
124 // (embedding KNN against user's liked articles), and recency. Scores are127 // (embedding KNN against user's liked articles), and recency. Scores are
125 // min-max normalized.128 // min-max normalized.
126 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {129 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
127- artKey := userDID + "|" + strings.Join(languages, ",")130+ if data, ok := e.getPrecomputed(ctx, userDID, "article"); ok {
128- if entry, ok := e.articleCache.Get(artKey); ok {131+ var recs []*ArticleRecommendation
129- return entry, nil132+ if err := json.Unmarshal([]byte(data), &recs); err == nil {
133+ return recs, nil
134+ }
130 }135 }
131 136
132 recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)137 recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)
@@ -134,47 +139,17 @@ func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string,
134 return nil, err139 return nil, err
135 }140 }
136 normalizeArticleScores(recs)141 normalizeArticleScores(recs)
137- e.articleCache.Add(artKey, recs)
138 return recs, nil142 return recs, nil
139 }143 }
140 144
141 func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) {145 func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) {
142- if offset == 0 {
143- if entry, ok := e.globalTrendingCache.Get(userDID); ok {
144- return entry, nil
145- }
146- }
147-
148 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)146 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
149- items, err := e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset)147+ return e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset)
150- if err != nil {
151- return nil, err
152- }
153-
154- if offset == 0 {
155- e.globalTrendingCache.Add(userDID, items)
156- }
157- return items, nil
158 }148 }
159 149
160 func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) {150 func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) {
161- key := userDID + "|" + strings.Join(languages, ",")
162- if offset == 0 {
163- if entry, ok := e.personalTrendingCache.Get(key); ok {
164- return entry, nil
165- }
166- }
167-
168 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)151 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
169- items, err := e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset)152+ return e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset)
170- if err != nil {
171- return nil, err
172- }
173-
174- if offset == 0 {
175- e.personalTrendingCache.Add(key, items)
176- }
177- return items, nil
178 }153 }
179 154
180 // SignalWeights holds per-signal multipliers used in the recommendation scoring155 // SignalWeights holds per-signal multipliers used in the recommendation scoring
modified internal/db/db.go +8 -0
@@ -449,6 +449,14 @@ var recsSchema = []string{
449449
450450 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
451451 `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,
452+
453+ `CREATE TABLE IF NOT EXISTS recs.precomputed_recommendations (
454+ user_did TEXT NOT NULL,
455+ rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')),
456+ data TEXT NOT NULL,
457+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
458+ PRIMARY KEY (user_did, rec_type)
459+ )`,
452460 }
453461
454462 func NullStr(s string) sql.NullString {
@@ -449,6 +449,14 @@ var recsSchema = []string{
449 449
450 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,450 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
451 `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,451 `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,
452+
453+ `CREATE TABLE IF NOT EXISTS recs.precomputed_recommendations (
454+ user_did TEXT NOT NULL,
455+ rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')),
456+ data TEXT NOT NULL,
457+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
458+ PRIMARY KEY (user_did, rec_type)
459+ )`,
452 }460 }
453 461
454 func NullStr(s string) sql.NullString {462 func NullStr(s string) sql.NullString {
modified main.go +1 -1
@@ -101,7 +101,7 @@ func main() {
101101 })
102102 }
103103
104- engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())
104+ engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())
105105
106106 fetcher := feed.NewFetcher(siteFetcher)
107107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
@@ -101,7 +101,7 @@ func main() {
101 })101 })
102 }102 }
103 103
104- engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())104+ engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())
105 105
106 fetcher := feed.NewFetcher(siteFetcher)106 fetcher := feed.NewFetcher(siteFetcher)
107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)107 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
modified readme.md +1 -1
@@ -19,7 +19,7 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away
1919
2020 ## How recommendations work
2121
22-Glean looks at what you and other users subscribe to, read, and like to suggest feeds and people you might enjoy.
22+Glean looks at what you and other users subscribe to, read, and like to suggest feeds, articles, and people you might enjoy.
2323
2424 **Feed suggestions** come from readers who share your subscriptions. If a lot of people who follow the same blogs as you also follow a blog you haven't seen, that blog shows up as a recommendation. The system also considers which articles you've liked, whether you follow the person on Bluesky, and how popular the feed is overall.
2525
@@ -19,7 +19,7 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away
19 19
20 ## How recommendations work20 ## How recommendations work
21 21
22-Glean looks at what you and other users subscribe to, read, and like to suggest feeds and people you might enjoy.22+Glean looks at what you and other users subscribe to, read, and like to suggest feeds, articles, and people you might enjoy.
23 23
24 **Feed suggestions** come from readers who share your subscriptions. If a lot of people who follow the same blogs as you also follow a blog you haven't seen, that blog shows up as a recommendation. The system also considers which articles you've liked, whether you follow the person on Bluesky, and how popular the feed is overall.24 **Feed suggestions** come from readers who share your subscriptions. If a lot of people who follow the same blogs as you also follow a blog you haven't seen, that blog shows up as a recommendation. The system also considers which articles you've liked, whether you follow the person on Bluesky, and how popular the feed is overall.
25 25