nandi/gleanpublic Fork 0
17db2c9
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.

Overhaul recommendation system and improve performanceUnverified

Julien Robert committed 2026-04-26T01:31:34+02:00 Browse files
17db2c9 parent: 4c84177
modified .env.example +7 -0
@@ -11,3 +11,10 @@ GLEAN_BACKFILL_CONCURRENCY=5
1111 # Leave empty for localhost OAuth (development)
1212 # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
1313 # GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback
14+# Embeddings (recommended — powers content-based feed/article recommendations)
15+# Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.)
16+# Without embeddings, recommendations rely only on subscription overlap and social graph.
17+GLEAN_EMBED_BASE_URL=https://api.openai.com/v1
18+GLEAN_EMBED_API_KEY=sk-...
19+GLEAN_EMBED_MODEL=text-embedding-3-small
20+GLEAN_EMBED_DIMENSION=1536
@@ -11,3 +11,10 @@ GLEAN_BACKFILL_CONCURRENCY=5
11 # Leave empty for localhost OAuth (development)11 # Leave empty for localhost OAuth (development)
12 # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata12 # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
13 # GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback13 # GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback
14+# Embeddings (recommended — powers content-based feed/article recommendations)
15+# Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.)
16+# Without embeddings, recommendations rely only on subscription overlap and social graph.
17+GLEAN_EMBED_BASE_URL=https://api.openai.com/v1
18+GLEAN_EMBED_API_KEY=sk-...
19+GLEAN_EMBED_MODEL=text-embedding-3-small
20+GLEAN_EMBED_DIMENSION=1536
modified Dockerfile +1 -0
@@ -13,6 +13,7 @@ COPY . .
1313 RUN npx tailwindcss -i ./static/input.css -o ./static/output.css --minify
1414
1515 RUN --mount=type=cache,target=/root/.cache/go-build \
16+ CGO_CFLAGS="-I/src/internal/db/include -I$(go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(grep 'mattn/go-sqlite3' go.mod | awk '{print $2}')" \
1617 CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean .
1718
1819 FROM alpine:3.21
@@ -13,6 +13,7 @@ COPY . .
13 RUN npx tailwindcss -i ./static/input.css -o ./static/output.css --minify13 RUN npx tailwindcss -i ./static/input.css -o ./static/output.css --minify
14 14
15 RUN --mount=type=cache,target=/root/.cache/go-build \15 RUN --mount=type=cache,target=/root/.cache/go-build \
16+ CGO_CFLAGS="-I/src/internal/db/include -I$(go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(grep 'mattn/go-sqlite3' go.mod | awk '{print $2}')" \
16 CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean .17 CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean .
17 18
18 FROM alpine:3.2119 FROM alpine:3.21
modified Makefile +4 -0
@@ -1,3 +1,7 @@
1+SQLITE3_VER := $(shell grep 'mattn/go-sqlite3' go.mod | awk '{print $$2}')
2+SQLITE3_INC := $(shell go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(SQLITE3_VER)
3+export CGO_CFLAGS := -I$(CURDIR)/internal/db/include -I$(SQLITE3_INC)
4+
15 .PHONY: tools-install
26 tools-install:
37 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
@@ -1,3 +1,7 @@
1+SQLITE3_VER := $(shell grep 'mattn/go-sqlite3' go.mod | awk '{print $$2}')
2+SQLITE3_INC := $(shell go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(SQLITE3_VER)
3+export CGO_CFLAGS := -I$(CURDIR)/internal/db/include -I$(SQLITE3_INC)
4+
1 .PHONY: tools-install5 .PHONY: tools-install
2 tools-install:6 tools-install:
3 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest7 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
modified docs/specs.md +95 -23
@@ -13,7 +13,7 @@ The core idea: your RSS subscriptions are a strong signal about your interests.
1313 | Layer | Technology |
1414 | ---------------- | --------------------------------------------------------------- |
1515 | Backend | Go |
16-| Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3`) |
16+| Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3` + `sqlite-vec` for vector search) |
1717 | Frontend | htmx + TailwindCSS |
1818 | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |
1919 | AT Protocol role | AppView for `at.glean.*` lexicons |
@@ -460,9 +460,10 @@ Profile data (handle, display name, avatar) is resolved on-the-fly via AT Protoc
460460
461461 ```sql
462462 CREATE TABLE users (
463- did TEXT PRIMARY KEY,
464- indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
465- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
463+ did TEXT PRIMARY KEY,
464+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
465+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
466+ follows_dirty BOOLEAN NOT NULL DEFAULT 1
466467 );
467468 ```
468469
@@ -649,9 +650,10 @@ Glean uses a multi-signal recommendation system that combines subscription overl
649650 | Subscription | `subscriptions` | 1.0 | Jaccard over subscriber sets between similar users |
650651 | Like | `likes` | 0.5 | Time-decayed like co-occurrence (30-day half-life) |
651652 | Tag | `annotations.tags` | 0.3 | Jaccard over annotation tag sets |
652-| Social | `follow_distances` | 0.7 | Follow distance: 1-hop=1.0, 2-hop=0.3 |
653+| Social | `follow_distances` | 0.7 | Follow distance: 1-hop=1.0, 2-hop=0.3, 3-hop=0.1 |
653654 | Popularity | `feeds.subscriber_count` | 0.2 | `log(1 + subscribers) / log(1 + max)` |
654655 | Category | `subscriptions.category` | 0.4 | Boost feeds matching user's existing categories |
656+| Content | `article_embeddings` | 0.4 | Cosine similarity via embedding KNN (requires embedder) |
655657
656658 ### 7.2 Feed Co-occurrence (Jaccard Similarity)
657659
@@ -661,7 +663,7 @@ For any two feeds, the similarity is the Jaccard index of their subscriber sets:
661663 J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|
662664 ```
663665
664-Feed description text similarity is also computed (word overlap after stopword removal) and added as a boost.
666+Feed description similarity is also computed via embedding cosine similarity (requires embedder) and added as a boost.
665667
666668 ### 7.3 User Similarity
667669
@@ -700,9 +702,12 @@ Where:
700702 ```
701703 score = like_signal * w_like
702704 + social_signal * w_social
705+ + content_signal * w_content
703706 + recency_signal * 0.2
704707 ```
705708
709+Content signal uses embedding vectors: the user's liked article embeddings are averaged into a single interest vector, then a KNN query against the `article_embeddings` vec0 table finds semantically similar articles. This requires an embedder to be configured; without it, the content signal is 0.
710+
706711 ### 7.5 User Feedback (Dismiss)
707712
708713 Users can dismiss recommendations they don't want to see again:
@@ -711,7 +716,7 @@ Users can dismiss recommendations they don't want to see again:
711716 - `POST /articles/dismiss` — dismiss an article recommendation
712717 - Dismissals are stored locally in `dismissed_recommendations` (not on PDS)
713718 - Dismissed items are excluded from all future recommendation queries
714-- Auto-dismiss: items shown >15 times over >30 days without action are auto-dismissed
719+- Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed
715720
716721 Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it.
717722
@@ -729,11 +734,11 @@ new_weight = MAX(0.1, MIN(3.0, old_weight * (1 + learning_rate * delta)))
729734
730735 ### 7.7 Social Graph
731736
732-Follow distances (1-hop and 2-hop) are pre-computed in `follow_distances` during the cron job:
737+Follow distances (1-hop through 3-hop) are computed incrementally. A `follows_dirty` column on `users` tracks whose follow graph changed since the last cron run. Only dirty users are reprocessed — their existing rows in `follow_distances` are deleted and recomputed via BFS, then the dirty flag is cleared.
733738
734739 - 1-hop: direct follows (weight 1.0)
735740 - 2-hop: friends-of-friends (weight 0.3)
736-- 3-hop is excluded due to noise and computational cost
741+- 3-hop: third-degree connections (weight 0.1)
737742
738743 ### 7.8 Diversity & Freshness
739744
@@ -754,15 +759,19 @@ New users with <5 subscriptions get a fallback strategy:
754759
755760 A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m):
756761
757-1. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + description similarity)
758-2. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)
759-3. **Compute follow distances**: 1-hop and 2-hop from `follows` table
760-4. **Compute signal profiles**: Per-user category/tag/like summaries
761-5. **Auto-dismiss stale**: Dismiss items shown >15 times over >30 days without action
762+1. **Compute feed embeddings**: Embed new feed descriptions via OpenAI-compatible API into `feed_embeddings` table (skipped if no embedder configured)
763+2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)
764+3. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)
765+4. **Compute article embeddings**: Embed new articles' full content (`title + summary + full_content + content`) via OpenAI-compatible API into `article_embeddings` vec0 table (skipped if no embedder configured)
766+5. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
767+6. **Compute signal profiles**: Per-user category/tag/like summaries
768+7. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
762769
763770 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
764771
765-### 7.11 Recommendation Tables (`<base>_recs`)
772+### 7.11 User Interaction Tables (`<base>_users`)
773+
774+Per-user interaction state lives in the users database so that real-time writes (impressions, dismissals) never contend with cron batch writes to the recs database.
766775
767776 ```sql
768777 CREATE TABLE dismissed_recommendations (
@@ -784,11 +793,38 @@ CREATE TABLE recommendation_impressions (
784793 acted BOOLEAN NOT NULL DEFAULT 0,
785794 PRIMARY KEY (user_did, target_type, target_id)
786795 );
796+```
797+
798+### 7.12 Computed Recommendation Tables (`<base>_recs`)
799+
800+Written exclusively by the cron. No user-facing writes — only reads during on-demand scoring.
801+
802+```sql
803+CREATE TABLE feed_similarity (
804+ feed_a TEXT NOT NULL,
805+ feed_b TEXT NOT NULL,
806+ jaccard REAL NOT NULL,
807+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
808+ PRIMARY KEY (feed_a, feed_b),
809+ CHECK(feed_a < feed_b)
810+);
811+
812+CREATE TABLE user_similarity (
813+ user_a TEXT NOT NULL,
814+ user_b TEXT NOT NULL,
815+ jaccard REAL NOT NULL,
816+ common_feeds INTEGER NOT NULL,
817+ common_likes INTEGER NOT NULL DEFAULT 0,
818+ common_tags INTEGER NOT NULL DEFAULT 0,
819+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
820+ PRIMARY KEY (user_a, user_b),
821+ CHECK(user_a < user_b)
822+);
787823
788824 CREATE TABLE follow_distances (
789825 user_a TEXT NOT NULL,
790826 user_b TEXT NOT NULL,
791- distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
827+ distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)),
792828 PRIMARY KEY (user_a, user_b)
793829 );
794830
@@ -800,6 +836,7 @@ CREATE TABLE user_signal_weights (
800836 w_social REAL NOT NULL DEFAULT 0.7,
801837 w_pop REAL NOT NULL DEFAULT 0.2,
802838 w_category REAL NOT NULL DEFAULT 0.4,
839+ w_content REAL NOT NULL DEFAULT 0.4,
803840 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
804841 );
805842
@@ -808,10 +845,42 @@ CREATE TABLE user_signal_profiles (
808845 total_likes INTEGER NOT NULL DEFAULT 0,
809846 total_tags INTEGER NOT NULL DEFAULT 0,
810847 top_categories TEXT,
848+ top_tags TEXT,
811849 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
812850 );
813851 ```
814852
853+### 7.13 Embeddings (recommended)
854+
855+When `GLEAN_EMBED_BASE_URL` is configured, article text and feed descriptions are embedded into vectors stored in `sqlite-vec` virtual tables (`recs.feed_embeddings`, `recs.article_embeddings`). The vec0 extension provides native KNN vector search via `WHERE embedding MATCH ? AND k = ?`, replacing Go-side cosine similarity for large-scale lookups. Without embeddings, recommendations rely only on subscription overlap, like patterns, and social graph — no content-based signals.
856+
857+The embedder uses the official `github.com/openai/openai-go` SDK with `option.WithBaseURL()`, so any OpenAI-compatible `/v1/embeddings` endpoint works (OpenAI, Ollama, local inference servers).
858+
859+vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):
860+
861+```sql
862+CREATE VIRTUAL TABLE recs.feed_embeddings USING vec0(
863+ feed_url TEXT PRIMARY KEY,
864+ embedding float[1536]
865+);
866+
867+CREATE VIRTUAL TABLE recs.article_embeddings USING vec0(
868+ article_id INTEGER PRIMARY KEY,
869+ embedding float[1536]
870+);
871+```
872+
873+Since vec0 virtual tables cannot hold metadata columns, a side table tracks the source text for re-embedding on description changes:
874+
875+```sql
876+CREATE TABLE recs.feed_embedding_meta (
877+ feed_url TEXT PRIMARY KEY,
878+ source_text TEXT NOT NULL DEFAULT ''
879+);
880+```
881+
882+During cron, `ComputeArticleEmbeddings` embeds new articles in batches (using `title + summary + full_content + content` for maximum semantic coverage) and inserts them into the vec0 table. `ComputeFeedEmbeddings` embeds feed descriptions (`title || description`) and re-embeds when the source text changes (detected via `feed_embedding_meta`). During on-demand article recommendations, the user's liked article embeddings are averaged into an interest vector, then a vec0 KNN query finds the top-200 most semantically similar articles. For cold-start users (<5 subscriptions), their subscribed feed embeddings are averaged and a KNN query finds similar feeds.
883+
815884 ## 8. HTTP API / htmx Endpoints
816885
817886 The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.
@@ -903,8 +972,10 @@ glean/
903972 │ │ └── metrics.go # Prometheus metrics definitions
904973 │ ├── cluster/
905974 │ │ ├── jaccard.go # Jaccard similarity computation
975+│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation
976+│ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost
906977 │ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)
907-│ │ ├── social.go # Follow-distance computation (1-2 hop)
978+│ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
908979 │ │ ├── dismiss.go # Dismiss + impression tracking
909980 │ │ ├── weights.go # Bandit-style signal weight auto-tuning
910981 │ │ ├── diversity.go # Post-query domain/category diversity filtering
@@ -994,12 +1065,13 @@ Browser ──GET /articles──► Server
9941065
9951066 ```
9961067 Cron (every 10m) ──► Cluster Engine
997-
998- ├─► Compute feed similarity
999- ├─► Compute user similarity
1000- ├─► Compute follow distances
1001- ├─► Compute signal profiles
1002- └─► Auto-dismiss stale recommendations
1068+
1069+ ├─► Compute feed similarity
1070+ ├─► Compute user similarity
1071+ ├─► Compute article embeddings (if embedder configured)
1072+ ├─► Compute follow distances
1073+ ├─► Compute signal profiles
1074+ └─► Auto-dismiss stale recommendations
10031075
10041076 Browser ──GET /dashboard──► Server
10051077
@@ -13,7 +13,7 @@ The core idea: your RSS subscriptions are a strong signal about your interests.
13 | Layer | Technology |13 | Layer | Technology |
14 | ---------------- | --------------------------------------------------------------- |14 | ---------------- | --------------------------------------------------------------- |
15 | Backend | Go |15 | Backend | Go |
16-| Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3`) |16+| Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3` + `sqlite-vec` for vector search) |
17 | Frontend | htmx + TailwindCSS |17 | Frontend | htmx + TailwindCSS |
18 | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |18 | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |
19 | AT Protocol role | AppView for `at.glean.*` lexicons |19 | AT Protocol role | AppView for `at.glean.*` lexicons |
@@ -460,9 +460,10 @@ Profile data (handle, display name, avatar) is resolved on-the-fly via AT Protoc
460 460
461 ```sql461 ```sql
462 CREATE TABLE users (462 CREATE TABLE users (
463- did TEXT PRIMARY KEY,463+ did TEXT PRIMARY KEY,
464- indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,464+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
465- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP465+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
466+ follows_dirty BOOLEAN NOT NULL DEFAULT 1
466 );467 );
467 ```468 ```
468 469
@@ -649,9 +650,10 @@ Glean uses a multi-signal recommendation system that combines subscription overl
649 | Subscription | `subscriptions` | 1.0 | Jaccard over subscriber sets between similar users |650 | Subscription | `subscriptions` | 1.0 | Jaccard over subscriber sets between similar users |
650 | Like | `likes` | 0.5 | Time-decayed like co-occurrence (30-day half-life) |651 | Like | `likes` | 0.5 | Time-decayed like co-occurrence (30-day half-life) |
651 | Tag | `annotations.tags` | 0.3 | Jaccard over annotation tag sets |652 | Tag | `annotations.tags` | 0.3 | Jaccard over annotation tag sets |
652-| Social | `follow_distances` | 0.7 | Follow distance: 1-hop=1.0, 2-hop=0.3 |653+| Social | `follow_distances` | 0.7 | Follow distance: 1-hop=1.0, 2-hop=0.3, 3-hop=0.1 |
653 | Popularity | `feeds.subscriber_count` | 0.2 | `log(1 + subscribers) / log(1 + max)` |654 | Popularity | `feeds.subscriber_count` | 0.2 | `log(1 + subscribers) / log(1 + max)` |
654 | Category | `subscriptions.category` | 0.4 | Boost feeds matching user's existing categories |655 | Category | `subscriptions.category` | 0.4 | Boost feeds matching user's existing categories |
656+| Content | `article_embeddings` | 0.4 | Cosine similarity via embedding KNN (requires embedder) |
655 657
656 ### 7.2 Feed Co-occurrence (Jaccard Similarity)658 ### 7.2 Feed Co-occurrence (Jaccard Similarity)
657 659
@@ -661,7 +663,7 @@ For any two feeds, the similarity is the Jaccard index of their subscriber sets:
661 J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|663 J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|
662 ```664 ```
663 665
664-Feed description text similarity is also computed (word overlap after stopword removal) and added as a boost.666+Feed description similarity is also computed via embedding cosine similarity (requires embedder) and added as a boost.
665 667
666 ### 7.3 User Similarity668 ### 7.3 User Similarity
667 669
@@ -700,9 +702,12 @@ Where:
700 ```702 ```
701 score = like_signal * w_like703 score = like_signal * w_like
702 + social_signal * w_social704 + social_signal * w_social
705+ + content_signal * w_content
703 + recency_signal * 0.2706 + recency_signal * 0.2
704 ```707 ```
705 708
709+Content signal uses embedding vectors: the user's liked article embeddings are averaged into a single interest vector, then a KNN query against the `article_embeddings` vec0 table finds semantically similar articles. This requires an embedder to be configured; without it, the content signal is 0.
710+
706 ### 7.5 User Feedback (Dismiss)711 ### 7.5 User Feedback (Dismiss)
707 712
708 Users can dismiss recommendations they don't want to see again:713 Users can dismiss recommendations they don't want to see again:
@@ -711,7 +716,7 @@ Users can dismiss recommendations they don't want to see again:
711 - `POST /articles/dismiss` — dismiss an article recommendation716 - `POST /articles/dismiss` — dismiss an article recommendation
712 - Dismissals are stored locally in `dismissed_recommendations` (not on PDS)717 - Dismissals are stored locally in `dismissed_recommendations` (not on PDS)
713 - Dismissed items are excluded from all future recommendation queries718 - Dismissed items are excluded from all future recommendation queries
714-- Auto-dismiss: items shown >15 times over >30 days without action are auto-dismissed719+- Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed
715 720
716 Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it.721 Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it.
717 722
@@ -729,11 +734,11 @@ new_weight = MAX(0.1, MIN(3.0, old_weight * (1 + learning_rate * delta)))
729 734
730 ### 7.7 Social Graph735 ### 7.7 Social Graph
731 736
732-Follow distances (1-hop and 2-hop) are pre-computed in `follow_distances` during the cron job:737+Follow distances (1-hop through 3-hop) are computed incrementally. A `follows_dirty` column on `users` tracks whose follow graph changed since the last cron run. Only dirty users are reprocessed — their existing rows in `follow_distances` are deleted and recomputed via BFS, then the dirty flag is cleared.
733 738
734 - 1-hop: direct follows (weight 1.0)739 - 1-hop: direct follows (weight 1.0)
735 - 2-hop: friends-of-friends (weight 0.3)740 - 2-hop: friends-of-friends (weight 0.3)
736-- 3-hop is excluded due to noise and computational cost741+- 3-hop: third-degree connections (weight 0.1)
737 742
738 ### 7.8 Diversity & Freshness743 ### 7.8 Diversity & Freshness
739 744
@@ -754,15 +759,19 @@ New users with <5 subscriptions get a fallback strategy:
754 759
755 A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m):760 A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m):
756 761
757-1. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + description similarity)762+1. **Compute feed embeddings**: Embed new feed descriptions via OpenAI-compatible API into `feed_embeddings` table (skipped if no embedder configured)
758-2. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)763+2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)
759-3. **Compute follow distances**: 1-hop and 2-hop from `follows` table764+3. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)
760-4. **Compute signal profiles**: Per-user category/tag/like summaries765+4. **Compute article embeddings**: Embed new articles' full content (`title + summary + full_content + content`) via OpenAI-compatible API into `article_embeddings` vec0 table (skipped if no embedder configured)
761-5. **Auto-dismiss stale**: Dismiss items shown >15 times over >30 days without action766+5. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
767+6. **Compute signal profiles**: Per-user category/tag/like summaries
768+7. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
762 769
763 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.770 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
764 771
765-### 7.11 Recommendation Tables (`<base>_recs`)772+### 7.11 User Interaction Tables (`<base>_users`)
773+
774+Per-user interaction state lives in the users database so that real-time writes (impressions, dismissals) never contend with cron batch writes to the recs database.
766 775
767 ```sql776 ```sql
768 CREATE TABLE dismissed_recommendations (777 CREATE TABLE dismissed_recommendations (
@@ -784,11 +793,38 @@ CREATE TABLE recommendation_impressions (
784 acted BOOLEAN NOT NULL DEFAULT 0,793 acted BOOLEAN NOT NULL DEFAULT 0,
785 PRIMARY KEY (user_did, target_type, target_id)794 PRIMARY KEY (user_did, target_type, target_id)
786 );795 );
796+```
797+
798+### 7.12 Computed Recommendation Tables (`<base>_recs`)
799+
800+Written exclusively by the cron. No user-facing writes — only reads during on-demand scoring.
801+
802+```sql
803+CREATE TABLE feed_similarity (
804+ feed_a TEXT NOT NULL,
805+ feed_b TEXT NOT NULL,
806+ jaccard REAL NOT NULL,
807+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
808+ PRIMARY KEY (feed_a, feed_b),
809+ CHECK(feed_a < feed_b)
810+);
811+
812+CREATE TABLE user_similarity (
813+ user_a TEXT NOT NULL,
814+ user_b TEXT NOT NULL,
815+ jaccard REAL NOT NULL,
816+ common_feeds INTEGER NOT NULL,
817+ common_likes INTEGER NOT NULL DEFAULT 0,
818+ common_tags INTEGER NOT NULL DEFAULT 0,
819+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
820+ PRIMARY KEY (user_a, user_b),
821+ CHECK(user_a < user_b)
822+);
787 823
788 CREATE TABLE follow_distances (824 CREATE TABLE follow_distances (
789 user_a TEXT NOT NULL,825 user_a TEXT NOT NULL,
790 user_b TEXT NOT NULL,826 user_b TEXT NOT NULL,
791- distance INTEGER NOT NULL CHECK(distance IN (1, 2)),827+ distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)),
792 PRIMARY KEY (user_a, user_b)828 PRIMARY KEY (user_a, user_b)
793 );829 );
794 830
@@ -800,6 +836,7 @@ CREATE TABLE user_signal_weights (
800 w_social REAL NOT NULL DEFAULT 0.7,836 w_social REAL NOT NULL DEFAULT 0.7,
801 w_pop REAL NOT NULL DEFAULT 0.2,837 w_pop REAL NOT NULL DEFAULT 0.2,
802 w_category REAL NOT NULL DEFAULT 0.4,838 w_category REAL NOT NULL DEFAULT 0.4,
839+ w_content REAL NOT NULL DEFAULT 0.4,
803 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP840 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
804 );841 );
805 842
@@ -808,10 +845,42 @@ CREATE TABLE user_signal_profiles (
808 total_likes INTEGER NOT NULL DEFAULT 0,845 total_likes INTEGER NOT NULL DEFAULT 0,
809 total_tags INTEGER NOT NULL DEFAULT 0,846 total_tags INTEGER NOT NULL DEFAULT 0,
810 top_categories TEXT,847 top_categories TEXT,
848+ top_tags TEXT,
811 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP849 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
812 );850 );
813 ```851 ```
814 852
853+### 7.13 Embeddings (recommended)
854+
855+When `GLEAN_EMBED_BASE_URL` is configured, article text and feed descriptions are embedded into vectors stored in `sqlite-vec` virtual tables (`recs.feed_embeddings`, `recs.article_embeddings`). The vec0 extension provides native KNN vector search via `WHERE embedding MATCH ? AND k = ?`, replacing Go-side cosine similarity for large-scale lookups. Without embeddings, recommendations rely only on subscription overlap, like patterns, and social graph — no content-based signals.
856+
857+The embedder uses the official `github.com/openai/openai-go` SDK with `option.WithBaseURL()`, so any OpenAI-compatible `/v1/embeddings` endpoint works (OpenAI, Ollama, local inference servers).
858+
859+vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):
860+
861+```sql
862+CREATE VIRTUAL TABLE recs.feed_embeddings USING vec0(
863+ feed_url TEXT PRIMARY KEY,
864+ embedding float[1536]
865+);
866+
867+CREATE VIRTUAL TABLE recs.article_embeddings USING vec0(
868+ article_id INTEGER PRIMARY KEY,
869+ embedding float[1536]
870+);
871+```
872+
873+Since vec0 virtual tables cannot hold metadata columns, a side table tracks the source text for re-embedding on description changes:
874+
875+```sql
876+CREATE TABLE recs.feed_embedding_meta (
877+ feed_url TEXT PRIMARY KEY,
878+ source_text TEXT NOT NULL DEFAULT ''
879+);
880+```
881+
882+During cron, `ComputeArticleEmbeddings` embeds new articles in batches (using `title + summary + full_content + content` for maximum semantic coverage) and inserts them into the vec0 table. `ComputeFeedEmbeddings` embeds feed descriptions (`title || description`) and re-embeds when the source text changes (detected via `feed_embedding_meta`). During on-demand article recommendations, the user's liked article embeddings are averaged into an interest vector, then a vec0 KNN query finds the top-200 most semantically similar articles. For cold-start users (<5 subscriptions), their subscribed feed embeddings are averaged and a KNN query finds similar feeds.
883+
815 ## 8. HTTP API / htmx Endpoints884 ## 8. HTTP API / htmx Endpoints
816 885
817 The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.886 The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.
@@ -903,8 +972,10 @@ glean/
903 │ │ └── metrics.go # Prometheus metrics definitions972 │ │ └── metrics.go # Prometheus metrics definitions
904 │ ├── cluster/973 │ ├── cluster/
905 │ │ ├── jaccard.go # Jaccard similarity computation974 │ │ ├── jaccard.go # Jaccard similarity computation
975+│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation
976+│ │ ├── article.go # Article + feed embedding computation, vec0 KNN content boost
906 │ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)977 │ │ ├── scoring.go # Feed + people + article recommendation queries (on-demand)
907-│ │ ├── social.go # Follow-distance computation (1-2 hop)978+│ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
908 │ │ ├── dismiss.go # Dismiss + impression tracking979 │ │ ├── dismiss.go # Dismiss + impression tracking
909 │ │ ├── weights.go # Bandit-style signal weight auto-tuning980 │ │ ├── weights.go # Bandit-style signal weight auto-tuning
910 │ │ ├── diversity.go # Post-query domain/category diversity filtering981 │ │ ├── diversity.go # Post-query domain/category diversity filtering
@@ -994,12 +1065,13 @@ Browser ──GET /articles──► Server
994 1065
995 ```1066 ```
996 Cron (every 10m) ──► Cluster Engine1067 Cron (every 10m) ──► Cluster Engine
997-1068+
998- ├─► Compute feed similarity1069+ ├─► Compute feed similarity
999- ├─► Compute user similarity1070+ ├─► Compute user similarity
1000- ├─► Compute follow distances1071+ ├─► Compute article embeddings (if embedder configured)
1001- ├─► Compute signal profiles1072+ ├─► Compute follow distances
1002- └─► Auto-dismiss stale recommendations1073+ ├─► Compute signal profiles
1074+ └─► Auto-dismiss stale recommendations
1003 1075
1004 Browser ──GET /dashboard──► Server1076 Browser ──GET /dashboard──► Server
1005 1077
modified go.mod +8 -2
@@ -3,12 +3,16 @@ module pkg.rbrt.fr/glean
33 go 1.26.2
44
55 require (
6+ github.com/asg017/sqlite-vec-go-bindings v0.1.6
67 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d
78 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28
89 github.com/go-chi/chi/v5 v5.2.5
910 github.com/go-chi/cors v1.2.2
1011 github.com/mattn/go-sqlite3 v1.14.22
12+ github.com/openai/openai-go v1.12.0
1113 github.com/prometheus/client_golang v1.19.1
14+ github.com/prometheus/client_model v0.6.1
15+ github.com/prometheus/common v0.54.0
1216 go.uber.org/atomic v1.11.0
1317 golang.org/x/net v0.53.0
1418 golang.org/x/sync v0.20.0
@@ -35,10 +39,12 @@ require (
3539 github.com/multiformats/go-multibase v0.2.0 // indirect
3640 github.com/multiformats/go-multihash v0.2.3 // indirect
3741 github.com/multiformats/go-varint v0.0.7 // indirect
38- github.com/prometheus/client_model v0.6.1 // indirect
39- github.com/prometheus/common v0.54.0 // indirect
4042 github.com/prometheus/procfs v0.15.1 // indirect
4143 github.com/spaolacci/murmur3 v1.1.0 // indirect
44+ github.com/tidwall/gjson v1.14.4 // indirect
45+ github.com/tidwall/match v1.1.1 // indirect
46+ github.com/tidwall/pretty v1.2.1 // indirect
47+ github.com/tidwall/sjson v1.2.5 // indirect
4248 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
4349 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
4450 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
@@ -3,12 +3,16 @@ module pkg.rbrt.fr/glean
3 go 1.26.23 go 1.26.2
4 4
5 require (5 require (
6+ github.com/asg017/sqlite-vec-go-bindings v0.1.6
6 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d7 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d
7 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda288 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28
8 github.com/go-chi/chi/v5 v5.2.59 github.com/go-chi/chi/v5 v5.2.5
9 github.com/go-chi/cors v1.2.210 github.com/go-chi/cors v1.2.2
10 github.com/mattn/go-sqlite3 v1.14.2211 github.com/mattn/go-sqlite3 v1.14.22
12+ github.com/openai/openai-go v1.12.0
11 github.com/prometheus/client_golang v1.19.113 github.com/prometheus/client_golang v1.19.1
14+ github.com/prometheus/client_model v0.6.1
15+ github.com/prometheus/common v0.54.0
12 go.uber.org/atomic v1.11.016 go.uber.org/atomic v1.11.0
13 golang.org/x/net v0.53.017 golang.org/x/net v0.53.0
14 golang.org/x/sync v0.20.018 golang.org/x/sync v0.20.0
@@ -35,10 +39,12 @@ require (
35 github.com/multiformats/go-multibase v0.2.0 // indirect39 github.com/multiformats/go-multibase v0.2.0 // indirect
36 github.com/multiformats/go-multihash v0.2.3 // indirect40 github.com/multiformats/go-multihash v0.2.3 // indirect
37 github.com/multiformats/go-varint v0.0.7 // indirect41 github.com/multiformats/go-varint v0.0.7 // indirect
38- github.com/prometheus/client_model v0.6.1 // indirect
39- github.com/prometheus/common v0.54.0 // indirect
40 github.com/prometheus/procfs v0.15.1 // indirect42 github.com/prometheus/procfs v0.15.1 // indirect
41 github.com/spaolacci/murmur3 v1.1.0 // indirect43 github.com/spaolacci/murmur3 v1.1.0 // indirect
44+ github.com/tidwall/gjson v1.14.4 // indirect
45+ github.com/tidwall/match v1.1.1 // indirect
46+ github.com/tidwall/pretty v1.2.1 // indirect
47+ github.com/tidwall/sjson v1.2.5 // indirect
42 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect48 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
43 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect49 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
44 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect50 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
modified go.sum +14 -0
@@ -1,3 +1,5 @@
1+github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww=
2+github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
13 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
24 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
35 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM=
@@ -49,6 +51,8 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
4951 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
5052 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
5153 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
54+github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
55+github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
5256 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5357 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5458 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
@@ -63,6 +67,16 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b
6367 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
6468 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6569 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
70+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
71+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
72+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
73+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
74+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
75+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
76+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
77+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
78+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
79+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
6680 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
6781 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
6882 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
@@ -1,3 +1,5 @@
1+github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww=
2+github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
1 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=3 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=4 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM=5 github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM=
@@ -49,6 +51,8 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
49 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=51 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
50 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=52 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
51 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=53 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
54+github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
55+github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
52 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=56 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
53 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=57 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
54 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=58 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
@@ -63,6 +67,16 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b
63 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=67 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
64 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=68 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
65 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=69 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
70+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
71+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
72+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
73+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
74+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
75+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
76+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
77+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
78+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
79+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
66 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=80 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
67 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=81 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
68 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=82 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
added internal/cluster/article.go +378 -0
new file mode 100644
@@ -0,0 +1,378 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "log/slog"
8+ "strings"
9+
10+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
11+)
12+
13+const embedBatchSize = 100
14+
15+// ComputeArticleEmbeddings embeds new articles (title + summary) into the
16+// article_embeddings vec0 table. Skipped when no embedder is configured.
17+// Existing embeddings for deleted articles are cleaned up. Articles already
18+// embedded are not re-embedded.
19+func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
20+ if e.embedder == nil {
21+ e.logger.Debug("article embeddings skipped, no embedder")
22+ return nil
23+ }
24+
25+ conn, err := e.db.Conn(ctx)
26+ if err != nil {
27+ return err
28+ }
29+ defer conn.Close()
30+
31+ _, err = conn.ExecContext(ctx, `
32+ DELETE FROM recs.article_embeddings
33+ WHERE article_id NOT IN (SELECT id FROM articles.articles)
34+ `)
35+ if err != nil {
36+ return fmt.Errorf("clean stale article embeddings: %w", err)
37+ }
38+
39+ rows, err := conn.QueryContext(ctx, `
40+ SELECT a.id, COALESCE(a.title, '') || ' ' || COALESCE(a.summary, '') || ' ' || COALESCE(a.full_content, '') || ' ' || COALESCE(a.content, '')
41+ FROM articles.articles a
42+ WHERE (COALESCE(a.title, '') != '' OR COALESCE(a.summary, '') != '' OR COALESCE(a.full_content, '') != '' OR COALESCE(a.content, '') != '')
43+ AND a.id NOT IN (SELECT article_id FROM recs.article_embeddings)
44+ ORDER BY a.id
45+ `)
46+ if err != nil {
47+ return err
48+ }
49+
50+ type article struct {
51+ id int64
52+ text string
53+ }
54+ var batch []article
55+ for rows.Next() {
56+ var a article
57+ if err := rows.Scan(&a.id, &a.text); err != nil {
58+ rows.Close()
59+ return err
60+ }
61+ batch = append(batch, a)
62+ }
63+ rows.Close()
64+
65+ if len(batch) == 0 {
66+ e.logger.Info("article embeddings up to date")
67+ return nil
68+ }
69+
70+ for i := 0; i < len(batch); i += embedBatchSize {
71+ end := min(i+embedBatchSize, len(batch))
72+ sub := batch[i:end]
73+
74+ texts := make([]string, len(sub))
75+ for j, a := range sub {
76+ texts[j] = a.text
77+ }
78+
79+ embeddings, err := e.embedder.Embed(ctx, texts)
80+ if err != nil {
81+ return fmt.Errorf("embed batch %d: %w", i/embedBatchSize, err)
82+ }
83+
84+ tx, err := conn.BeginTx(ctx, nil)
85+ if err != nil {
86+ return err
87+ }
88+ defer func() { _ = tx.Rollback() }()
89+
90+ for j, emb := range embeddings {
91+ blob, err := vec.SerializeFloat32(emb)
92+ if err != nil {
93+ return fmt.Errorf("serialize embedding: %w", err)
94+ }
95+ if _, err := tx.ExecContext(ctx,
96+ `INSERT OR IGNORE INTO recs.article_embeddings(article_id, embedding) VALUES (?, ?)`,
97+ sub[j].id, blob,
98+ ); err != nil {
99+ return fmt.Errorf("insert embedding: %w", err)
100+ }
101+ }
102+
103+ if err := tx.Commit(); err != nil {
104+ return err
105+ }
106+
107+ e.logger.Info("article embeddings batch computed",
108+ slog.Int("batch", i/embedBatchSize),
109+ slog.Int("count", len(sub)),
110+ )
111+ }
112+
113+ e.logger.Info("article embeddings computed", slog.Int("total", len(batch)))
114+ return nil
115+}
116+
117+func (e *Engine) populateContentBoost(ctx context.Context, conn *sql.Conn, userDID string) error {
118+ rows, err := conn.QueryContext(ctx, `
119+ SELECT a.id FROM articles.likes ul
120+ JOIN articles.articles a ON a.feed_url = ul.feed_url AND a.url = ul.article_url
121+ WHERE ul.author_did = ?
122+ `, userDID)
123+ if err != nil {
124+ return err
125+ }
126+ var articleIDs []int64
127+ for rows.Next() {
128+ var id int64
129+ if err := rows.Scan(&id); err != nil {
130+ rows.Close()
131+ return err
132+ }
133+ articleIDs = append(articleIDs, id)
134+ }
135+ rows.Close()
136+
137+ if len(articleIDs) == 0 {
138+ return nil
139+ }
140+
141+ ph := make([]string, len(articleIDs))
142+ args := make([]any, len(articleIDs))
143+ for i, id := range articleIDs {
144+ ph[i] = "?"
145+ args[i] = id
146+ }
147+ embRows, err := conn.QueryContext(ctx,
148+ fmt.Sprintf("SELECT article_id, embedding FROM recs.article_embeddings WHERE article_id IN (%s)", joinPh(ph)),
149+ args...,
150+ )
151+ if err != nil {
152+ return err
153+ }
154+
155+ dim := e.embedder.Dimension()
156+ sumVec := make([]float32, dim)
157+ count := 0
158+ likedSet := make(map[int64]bool)
159+ for embRows.Next() {
160+ var id int64
161+ var blob []byte
162+ if err := embRows.Scan(&id, &blob); err != nil {
163+ embRows.Close()
164+ return err
165+ }
166+ v := deserializeFloat32(blob)
167+ if len(v) != dim {
168+ continue
169+ }
170+ for j := range sumVec {
171+ sumVec[j] += v[j]
172+ }
173+ count++
174+ likedSet[id] = true
175+ }
176+ embRows.Close()
177+
178+ if count == 0 {
179+ return nil
180+ }
181+
182+ avgVec := make([]float32, dim)
183+ for j := range avgVec {
184+ avgVec[j] = sumVec[j] / float32(count)
185+ }
186+
187+ queryBlob, err := vec.SerializeFloat32(avgVec)
188+ if err != nil {
189+ return fmt.Errorf("serialize query vector: %w", err)
190+ }
191+
192+ const topK = 200
193+ knnRows, err := conn.QueryContext(ctx, `
194+ SELECT article_id, distance FROM recs.article_embeddings
195+ WHERE embedding MATCH ? AND k = ?
196+ ORDER BY distance
197+ `, queryBlob, topK+len(likedSet))
198+ if err != nil {
199+ return err
200+ }
201+
202+ tx, err := conn.BeginTx(ctx, nil)
203+ if err != nil {
204+ return err
205+ }
206+ defer func() { _ = tx.Rollback() }()
207+
208+ for knnRows.Next() {
209+ var id int64
210+ var dist float64
211+ if err := knnRows.Scan(&id, &dist); err != nil {
212+ knnRows.Close()
213+ return err
214+ }
215+ if likedSet[id] {
216+ continue
217+ }
218+ score := 1.0 - dist
219+ if score <= 0 {
220+ continue
221+ }
222+ if _, err := tx.ExecContext(ctx,
223+ `INSERT OR IGNORE INTO _content_boost (article_id, score) VALUES (?, ?)`,
224+ id, score,
225+ ); err != nil {
226+ return err
227+ }
228+ }
229+ knnRows.Close()
230+
231+ return tx.Commit()
232+}
233+
234+// ComputeFeedEmbeddings embeds feed descriptions (title + description) into the
235+// feed_embeddings vec0 table and tracks source text in feed_embedding_meta for
236+// re-embedding on description change. Skipped when no embedder is configured.
237+func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
238+ if e.embedder == nil {
239+ e.logger.Debug("feed embeddings skipped, no embedder")
240+ return nil
241+ }
242+
243+ conn, err := e.db.Conn(ctx)
244+ if err != nil {
245+ return err
246+ }
247+ defer conn.Close()
248+
249+ _, err = conn.ExecContext(ctx, `
250+ DELETE FROM recs.feed_embeddings
251+ WHERE feed_url NOT IN (SELECT feed_url FROM articles.feeds)
252+ `)
253+ if err != nil {
254+ return fmt.Errorf("clean stale feed embeddings: %w", err)
255+ }
256+ _, err = conn.ExecContext(ctx, `
257+ DELETE FROM recs.feed_embedding_meta
258+ WHERE feed_url NOT IN (SELECT feed_url FROM articles.feeds)
259+ `)
260+ if err != nil {
261+ return fmt.Errorf("clean stale feed embeddings: %w", err)
262+ }
263+
264+ rows, err := conn.QueryContext(ctx, `
265+ SELECT f.feed_url, COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
266+ FROM articles.feeds f
267+ WHERE (COALESCE(f.title, '') != '' OR COALESCE(f.description, '') != '')
268+ AND (
269+ f.feed_url NOT IN (SELECT feed_url FROM recs.feed_embedding_meta)
270+ OR EXISTS (
271+ SELECT 1 FROM recs.feed_embedding_meta fm
272+ WHERE fm.feed_url = f.feed_url
273+ AND fm.source_text != COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
274+ )
275+ )
276+ ORDER BY f.feed_url
277+ `)
278+ if err != nil {
279+ return err
280+ }
281+
282+ type feed struct {
283+ url string
284+ text string
285+ }
286+ var batch []feed
287+ for rows.Next() {
288+ var f feed
289+ if err := rows.Scan(&f.url, &f.text); err != nil {
290+ rows.Close()
291+ return err
292+ }
293+ batch = append(batch, f)
294+ }
295+ rows.Close()
296+
297+ if len(batch) == 0 {
298+ e.logger.Info("feed embeddings up to date")
299+ return nil
300+ }
301+
302+ for i := 0; i < len(batch); i += embedBatchSize {
303+ end := min(i+embedBatchSize, len(batch))
304+ sub := batch[i:end]
305+
306+ texts := make([]string, len(sub))
307+ for j, f := range sub {
308+ texts[j] = f.text
309+ }
310+
311+ embeddings, err := e.embedder.Embed(ctx, texts)
312+ if err != nil {
313+ return fmt.Errorf("embed feed batch %d: %w", i/embedBatchSize, err)
314+ }
315+
316+ tx, err := conn.BeginTx(ctx, nil)
317+ if err != nil {
318+ return err
319+ }
320+ defer func() { _ = tx.Rollback() }()
321+
322+ for j, emb := range embeddings {
323+ blob, err := vec.SerializeFloat32(emb)
324+ if err != nil {
325+ return fmt.Errorf("serialize feed embedding: %w", err)
326+ }
327+ if _, err := tx.ExecContext(ctx,
328+ `DELETE FROM recs.feed_embeddings WHERE feed_url = ?`, sub[j].url,
329+ ); err != nil {
330+ return fmt.Errorf("delete feed embedding: %w", err)
331+ }
332+ if _, err := tx.ExecContext(ctx,
333+ `INSERT INTO recs.feed_embeddings(feed_url, embedding) VALUES (?, ?)`,
334+ sub[j].url, blob,
335+ ); err != nil {
336+ return fmt.Errorf("insert feed embedding: %w", err)
337+ }
338+ if _, err := tx.ExecContext(ctx,
339+ `INSERT OR REPLACE INTO recs.feed_embedding_meta(feed_url, source_text) VALUES (?, ?)`,
340+ sub[j].url, sub[j].text,
341+ ); err != nil {
342+ return fmt.Errorf("insert feed embedding: %w", err)
343+ }
344+ }
345+
346+ if err := tx.Commit(); err != nil {
347+ return err
348+ }
349+
350+ e.logger.Info("feed embeddings batch computed",
351+ slog.Int("batch", i/embedBatchSize),
352+ slog.Int("count", len(sub)),
353+ )
354+ }
355+
356+ e.logger.Info("feed embeddings computed", slog.Int("total", len(batch)))
357+ return nil
358+}
359+
360+func (e *Engine) ensureContentBoostTable(ctx context.Context, conn *sql.Conn) error {
361+ _, err := conn.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _content_boost (article_id INT PRIMARY KEY, score REAL)`)
362+ if err != nil {
363+ return err
364+ }
365+ _, err = conn.ExecContext(ctx, `DELETE FROM _content_boost`)
366+ return err
367+}
368+
369+func joinPh(ph []string) string {
370+ var s strings.Builder
371+ for i, p := range ph {
372+ if i > 0 {
373+ s.WriteString(",")
374+ }
375+ s.WriteString(p)
376+ }
377+ return s.String()
378+}
new file mode 100644
@@ -0,0 +1,378 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "log/slog"
8+ "strings"
9+
10+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
11+)
12+
13+const embedBatchSize = 100
14+
15+// ComputeArticleEmbeddings embeds new articles (title + summary) into the
16+// article_embeddings vec0 table. Skipped when no embedder is configured.
17+// Existing embeddings for deleted articles are cleaned up. Articles already
18+// embedded are not re-embedded.
19+func (e *Engine) ComputeArticleEmbeddings(ctx context.Context) error {
20+ if e.embedder == nil {
21+ e.logger.Debug("article embeddings skipped, no embedder")
22+ return nil
23+ }
24+
25+ conn, err := e.db.Conn(ctx)
26+ if err != nil {
27+ return err
28+ }
29+ defer conn.Close()
30+
31+ _, err = conn.ExecContext(ctx, `
32+ DELETE FROM recs.article_embeddings
33+ WHERE article_id NOT IN (SELECT id FROM articles.articles)
34+ `)
35+ if err != nil {
36+ return fmt.Errorf("clean stale article embeddings: %w", err)
37+ }
38+
39+ rows, err := conn.QueryContext(ctx, `
40+ SELECT a.id, COALESCE(a.title, '') || ' ' || COALESCE(a.summary, '') || ' ' || COALESCE(a.full_content, '') || ' ' || COALESCE(a.content, '')
41+ FROM articles.articles a
42+ WHERE (COALESCE(a.title, '') != '' OR COALESCE(a.summary, '') != '' OR COALESCE(a.full_content, '') != '' OR COALESCE(a.content, '') != '')
43+ AND a.id NOT IN (SELECT article_id FROM recs.article_embeddings)
44+ ORDER BY a.id
45+ `)
46+ if err != nil {
47+ return err
48+ }
49+
50+ type article struct {
51+ id int64
52+ text string
53+ }
54+ var batch []article
55+ for rows.Next() {
56+ var a article
57+ if err := rows.Scan(&a.id, &a.text); err != nil {
58+ rows.Close()
59+ return err
60+ }
61+ batch = append(batch, a)
62+ }
63+ rows.Close()
64+
65+ if len(batch) == 0 {
66+ e.logger.Info("article embeddings up to date")
67+ return nil
68+ }
69+
70+ for i := 0; i < len(batch); i += embedBatchSize {
71+ end := min(i+embedBatchSize, len(batch))
72+ sub := batch[i:end]
73+
74+ texts := make([]string, len(sub))
75+ for j, a := range sub {
76+ texts[j] = a.text
77+ }
78+
79+ embeddings, err := e.embedder.Embed(ctx, texts)
80+ if err != nil {
81+ return fmt.Errorf("embed batch %d: %w", i/embedBatchSize, err)
82+ }
83+
84+ tx, err := conn.BeginTx(ctx, nil)
85+ if err != nil {
86+ return err
87+ }
88+ defer func() { _ = tx.Rollback() }()
89+
90+ for j, emb := range embeddings {
91+ blob, err := vec.SerializeFloat32(emb)
92+ if err != nil {
93+ return fmt.Errorf("serialize embedding: %w", err)
94+ }
95+ if _, err := tx.ExecContext(ctx,
96+ `INSERT OR IGNORE INTO recs.article_embeddings(article_id, embedding) VALUES (?, ?)`,
97+ sub[j].id, blob,
98+ ); err != nil {
99+ return fmt.Errorf("insert embedding: %w", err)
100+ }
101+ }
102+
103+ if err := tx.Commit(); err != nil {
104+ return err
105+ }
106+
107+ e.logger.Info("article embeddings batch computed",
108+ slog.Int("batch", i/embedBatchSize),
109+ slog.Int("count", len(sub)),
110+ )
111+ }
112+
113+ e.logger.Info("article embeddings computed", slog.Int("total", len(batch)))
114+ return nil
115+}
116+
117+func (e *Engine) populateContentBoost(ctx context.Context, conn *sql.Conn, userDID string) error {
118+ rows, err := conn.QueryContext(ctx, `
119+ SELECT a.id FROM articles.likes ul
120+ JOIN articles.articles a ON a.feed_url = ul.feed_url AND a.url = ul.article_url
121+ WHERE ul.author_did = ?
122+ `, userDID)
123+ if err != nil {
124+ return err
125+ }
126+ var articleIDs []int64
127+ for rows.Next() {
128+ var id int64
129+ if err := rows.Scan(&id); err != nil {
130+ rows.Close()
131+ return err
132+ }
133+ articleIDs = append(articleIDs, id)
134+ }
135+ rows.Close()
136+
137+ if len(articleIDs) == 0 {
138+ return nil
139+ }
140+
141+ ph := make([]string, len(articleIDs))
142+ args := make([]any, len(articleIDs))
143+ for i, id := range articleIDs {
144+ ph[i] = "?"
145+ args[i] = id
146+ }
147+ embRows, err := conn.QueryContext(ctx,
148+ fmt.Sprintf("SELECT article_id, embedding FROM recs.article_embeddings WHERE article_id IN (%s)", joinPh(ph)),
149+ args...,
150+ )
151+ if err != nil {
152+ return err
153+ }
154+
155+ dim := e.embedder.Dimension()
156+ sumVec := make([]float32, dim)
157+ count := 0
158+ likedSet := make(map[int64]bool)
159+ for embRows.Next() {
160+ var id int64
161+ var blob []byte
162+ if err := embRows.Scan(&id, &blob); err != nil {
163+ embRows.Close()
164+ return err
165+ }
166+ v := deserializeFloat32(blob)
167+ if len(v) != dim {
168+ continue
169+ }
170+ for j := range sumVec {
171+ sumVec[j] += v[j]
172+ }
173+ count++
174+ likedSet[id] = true
175+ }
176+ embRows.Close()
177+
178+ if count == 0 {
179+ return nil
180+ }
181+
182+ avgVec := make([]float32, dim)
183+ for j := range avgVec {
184+ avgVec[j] = sumVec[j] / float32(count)
185+ }
186+
187+ queryBlob, err := vec.SerializeFloat32(avgVec)
188+ if err != nil {
189+ return fmt.Errorf("serialize query vector: %w", err)
190+ }
191+
192+ const topK = 200
193+ knnRows, err := conn.QueryContext(ctx, `
194+ SELECT article_id, distance FROM recs.article_embeddings
195+ WHERE embedding MATCH ? AND k = ?
196+ ORDER BY distance
197+ `, queryBlob, topK+len(likedSet))
198+ if err != nil {
199+ return err
200+ }
201+
202+ tx, err := conn.BeginTx(ctx, nil)
203+ if err != nil {
204+ return err
205+ }
206+ defer func() { _ = tx.Rollback() }()
207+
208+ for knnRows.Next() {
209+ var id int64
210+ var dist float64
211+ if err := knnRows.Scan(&id, &dist); err != nil {
212+ knnRows.Close()
213+ return err
214+ }
215+ if likedSet[id] {
216+ continue
217+ }
218+ score := 1.0 - dist
219+ if score <= 0 {
220+ continue
221+ }
222+ if _, err := tx.ExecContext(ctx,
223+ `INSERT OR IGNORE INTO _content_boost (article_id, score) VALUES (?, ?)`,
224+ id, score,
225+ ); err != nil {
226+ return err
227+ }
228+ }
229+ knnRows.Close()
230+
231+ return tx.Commit()
232+}
233+
234+// ComputeFeedEmbeddings embeds feed descriptions (title + description) into the
235+// feed_embeddings vec0 table and tracks source text in feed_embedding_meta for
236+// re-embedding on description change. Skipped when no embedder is configured.
237+func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
238+ if e.embedder == nil {
239+ e.logger.Debug("feed embeddings skipped, no embedder")
240+ return nil
241+ }
242+
243+ conn, err := e.db.Conn(ctx)
244+ if err != nil {
245+ return err
246+ }
247+ defer conn.Close()
248+
249+ _, err = conn.ExecContext(ctx, `
250+ DELETE FROM recs.feed_embeddings
251+ WHERE feed_url NOT IN (SELECT feed_url FROM articles.feeds)
252+ `)
253+ if err != nil {
254+ return fmt.Errorf("clean stale feed embeddings: %w", err)
255+ }
256+ _, err = conn.ExecContext(ctx, `
257+ DELETE FROM recs.feed_embedding_meta
258+ WHERE feed_url NOT IN (SELECT feed_url FROM articles.feeds)
259+ `)
260+ if err != nil {
261+ return fmt.Errorf("clean stale feed embeddings: %w", err)
262+ }
263+
264+ rows, err := conn.QueryContext(ctx, `
265+ SELECT f.feed_url, COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
266+ FROM articles.feeds f
267+ WHERE (COALESCE(f.title, '') != '' OR COALESCE(f.description, '') != '')
268+ AND (
269+ f.feed_url NOT IN (SELECT feed_url FROM recs.feed_embedding_meta)
270+ OR EXISTS (
271+ SELECT 1 FROM recs.feed_embedding_meta fm
272+ WHERE fm.feed_url = f.feed_url
273+ AND fm.source_text != COALESCE(f.title, '') || ' ' || COALESCE(f.description, '')
274+ )
275+ )
276+ ORDER BY f.feed_url
277+ `)
278+ if err != nil {
279+ return err
280+ }
281+
282+ type feed struct {
283+ url string
284+ text string
285+ }
286+ var batch []feed
287+ for rows.Next() {
288+ var f feed
289+ if err := rows.Scan(&f.url, &f.text); err != nil {
290+ rows.Close()
291+ return err
292+ }
293+ batch = append(batch, f)
294+ }
295+ rows.Close()
296+
297+ if len(batch) == 0 {
298+ e.logger.Info("feed embeddings up to date")
299+ return nil
300+ }
301+
302+ for i := 0; i < len(batch); i += embedBatchSize {
303+ end := min(i+embedBatchSize, len(batch))
304+ sub := batch[i:end]
305+
306+ texts := make([]string, len(sub))
307+ for j, f := range sub {
308+ texts[j] = f.text
309+ }
310+
311+ embeddings, err := e.embedder.Embed(ctx, texts)
312+ if err != nil {
313+ return fmt.Errorf("embed feed batch %d: %w", i/embedBatchSize, err)
314+ }
315+
316+ tx, err := conn.BeginTx(ctx, nil)
317+ if err != nil {
318+ return err
319+ }
320+ defer func() { _ = tx.Rollback() }()
321+
322+ for j, emb := range embeddings {
323+ blob, err := vec.SerializeFloat32(emb)
324+ if err != nil {
325+ return fmt.Errorf("serialize feed embedding: %w", err)
326+ }
327+ if _, err := tx.ExecContext(ctx,
328+ `DELETE FROM recs.feed_embeddings WHERE feed_url = ?`, sub[j].url,
329+ ); err != nil {
330+ return fmt.Errorf("delete feed embedding: %w", err)
331+ }
332+ if _, err := tx.ExecContext(ctx,
333+ `INSERT INTO recs.feed_embeddings(feed_url, embedding) VALUES (?, ?)`,
334+ sub[j].url, blob,
335+ ); err != nil {
336+ return fmt.Errorf("insert feed embedding: %w", err)
337+ }
338+ if _, err := tx.ExecContext(ctx,
339+ `INSERT OR REPLACE INTO recs.feed_embedding_meta(feed_url, source_text) VALUES (?, ?)`,
340+ sub[j].url, sub[j].text,
341+ ); err != nil {
342+ return fmt.Errorf("insert feed embedding: %w", err)
343+ }
344+ }
345+
346+ if err := tx.Commit(); err != nil {
347+ return err
348+ }
349+
350+ e.logger.Info("feed embeddings batch computed",
351+ slog.Int("batch", i/embedBatchSize),
352+ slog.Int("count", len(sub)),
353+ )
354+ }
355+
356+ e.logger.Info("feed embeddings computed", slog.Int("total", len(batch)))
357+ return nil
358+}
359+
360+func (e *Engine) ensureContentBoostTable(ctx context.Context, conn *sql.Conn) error {
361+ _, err := conn.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _content_boost (article_id INT PRIMARY KEY, score REAL)`)
362+ if err != nil {
363+ return err
364+ }
365+ _, err = conn.ExecContext(ctx, `DELETE FROM _content_boost`)
366+ return err
367+}
368+
369+func joinPh(ph []string) string {
370+ var s strings.Builder
371+ for i, p := range ph {
372+ if i > 0 {
373+ s.WriteString(",")
374+ }
375+ s.WriteString(p)
376+ }
377+ return s.String()
378+}
modified internal/cluster/cron.go +15 -3
@@ -8,16 +8,22 @@ import (
88 "pkg.rbrt.fr/glean/internal/metrics"
99 )
1010
11+// Cron periodically runs all cluster engine computations (similarity, embeddings,
12+// follow distances, signal profiles, auto-dismiss) on a fixed interval.
1113 type Cron struct {
1214 engine *Engine
1315 interval time.Duration
1416 logger *slog.Logger
1517 }
1618
19+// NewCron creates a new cron runner with the given engine and interval.
1720 func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {
1821 return &Cron{engine: engine, interval: interval, logger: logger}
1922 }
2023
24+// Run starts the cron loop. It blocks until ctx is cancelled. Each tick runs
25+// all computations sequentially; if a previous run is still in progress the
26+// tick is skipped.
2127 func (c *Cron) Run(ctx context.Context) error {
2228 for {
2329 c.logger.Info("starting similarity computation")
@@ -26,19 +32,25 @@ func (c *Cron) Run(ctx context.Context) error {
2632 if !c.engine.mu.TryLock() {
2733 c.logger.Info("skipping computation: already in progress")
2834 } else {
35+ if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {
36+ c.engine.logger.Error("feed embeddings failed", "error", err)
37+ }
2938 if err := c.engine.ComputeFeedSimilarity(ctx); err != nil {
3039 c.engine.logger.Error("feed similarity failed", "error", err)
3140 }
3241 if err := c.engine.ComputeUserSimilarity(ctx); err != nil {
3342 c.engine.logger.Error("user similarity failed", "error", err)
3443 }
35- if err := c.engine.ComputeFollowDistances(ctx); err != nil {
36- c.engine.logger.Error("follow distances failed", "error", err)
44+ if err := c.engine.ComputeArticleEmbeddings(ctx); err != nil {
45+ c.engine.logger.Error("article embeddings failed", "error", err)
3746 }
3847 if err := c.engine.ComputeSignalProfiles(ctx); err != nil {
3948 c.engine.logger.Error("signal profiles failed", "error", err)
4049 }
41- if err := c.engine.AutoDismissStale(ctx, 15, 30); err != nil {
50+ if err := c.engine.ComputeFollowDistances(ctx); err != nil {
51+ c.logger.Error("follow distances failed", "error", err)
52+ }
53+ if err := c.engine.AutoDismissStale(ctx, 5, 5); err != nil {
4254 c.engine.logger.Error("auto dismiss failed", "error", err)
4355 }
4456 c.engine.mu.Unlock()
@@ -8,16 +8,22 @@ import (
8 "pkg.rbrt.fr/glean/internal/metrics"8 "pkg.rbrt.fr/glean/internal/metrics"
9 )9 )
10 10
11+// Cron periodically runs all cluster engine computations (similarity, embeddings,
12+// follow distances, signal profiles, auto-dismiss) on a fixed interval.
11 type Cron struct {13 type Cron struct {
12 engine *Engine14 engine *Engine
13 interval time.Duration15 interval time.Duration
14 logger *slog.Logger16 logger *slog.Logger
15 }17 }
16 18
19+// NewCron creates a new cron runner with the given engine and interval.
17 func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {20 func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {
18 return &Cron{engine: engine, interval: interval, logger: logger}21 return &Cron{engine: engine, interval: interval, logger: logger}
19 }22 }
20 23
24+// Run starts the cron loop. It blocks until ctx is cancelled. Each tick runs
25+// all computations sequentially; if a previous run is still in progress the
26+// tick is skipped.
21 func (c *Cron) Run(ctx context.Context) error {27 func (c *Cron) Run(ctx context.Context) error {
22 for {28 for {
23 c.logger.Info("starting similarity computation")29 c.logger.Info("starting similarity computation")
@@ -26,19 +32,25 @@ func (c *Cron) Run(ctx context.Context) error {
26 if !c.engine.mu.TryLock() {32 if !c.engine.mu.TryLock() {
27 c.logger.Info("skipping computation: already in progress")33 c.logger.Info("skipping computation: already in progress")
28 } else {34 } else {
35+ if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil {
36+ c.engine.logger.Error("feed embeddings failed", "error", err)
37+ }
29 if err := c.engine.ComputeFeedSimilarity(ctx); err != nil {38 if err := c.engine.ComputeFeedSimilarity(ctx); err != nil {
30 c.engine.logger.Error("feed similarity failed", "error", err)39 c.engine.logger.Error("feed similarity failed", "error", err)
31 }40 }
32 if err := c.engine.ComputeUserSimilarity(ctx); err != nil {41 if err := c.engine.ComputeUserSimilarity(ctx); err != nil {
33 c.engine.logger.Error("user similarity failed", "error", err)42 c.engine.logger.Error("user similarity failed", "error", err)
34 }43 }
35- if err := c.engine.ComputeFollowDistances(ctx); err != nil {44+ if err := c.engine.ComputeArticleEmbeddings(ctx); err != nil {
36- c.engine.logger.Error("follow distances failed", "error", err)45+ c.engine.logger.Error("article embeddings failed", "error", err)
37 }46 }
38 if err := c.engine.ComputeSignalProfiles(ctx); err != nil {47 if err := c.engine.ComputeSignalProfiles(ctx); err != nil {
39 c.engine.logger.Error("signal profiles failed", "error", err)48 c.engine.logger.Error("signal profiles failed", "error", err)
40 }49 }
41- if err := c.engine.AutoDismissStale(ctx, 15, 30); err != nil {50+ if err := c.engine.ComputeFollowDistances(ctx); err != nil {
51+ c.logger.Error("follow distances failed", "error", err)
52+ }
53+ if err := c.engine.AutoDismissStale(ctx, 5, 5); err != nil {
42 c.engine.logger.Error("auto dismiss failed", "error", err)54 c.engine.logger.Error("auto dismiss failed", "error", err)
43 }55 }
44 c.engine.mu.Unlock()56 c.engine.mu.Unlock()
modified internal/cluster/dismiss.go +11 -11
@@ -5,15 +5,15 @@ import (
55 "time"
66 )
77
8+// Impression records that a recommendation was shown to a user.
89 type Impression struct {
910 TargetType string
1011 TargetID string
1112 }
1213
1314 func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {
14- return nil
1515 _, err := e.db.ExecContext(ctx, `
16- INSERT INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason)
16+ INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)
1717 VALUES (?, 'feed', ?, ?)
1818 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
1919 `, userDID, feedURL, reason)
@@ -21,9 +21,8 @@ func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason strin
2121 }
2222
2323 func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {
24- return nil
2524 _, err := e.db.ExecContext(ctx, `
26- INSERT INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason)
25+ INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)
2726 VALUES (?, 'article', ?, ?)
2827 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
2928 `, userDID, articleURL, reason)
@@ -31,7 +30,6 @@ func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason
3130 }
3231
3332 func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {
34- return nil
3533 tx, err := e.db.BeginTx(ctx, nil)
3634 if err != nil {
3735 return err
@@ -40,7 +38,7 @@ func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressi
4038
4139 for _, imp := range impressions {
4240 _, err := tx.ExecContext(ctx, `
43- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count)
41+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count)
4442 VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
4543 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET
4644 last_shown_at = CURRENT_TIMESTAMP,
@@ -54,21 +52,23 @@ func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressi
5452 }
5553
5654 func (e *Engine) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {
57- return nil
5855 _, err := e.db.ExecContext(ctx, `
59- UPDATE recs.recommendation_impressions SET acted = 1
56+ UPDATE main.recommendation_impressions SET acted = 1
6057 WHERE user_did = ? AND target_type = ? AND target_id = ?
6158 `, userDID, targetType, targetID)
6259 return err
6360 }
6461
62+// AutoDismissStale marks recommendations as dismissed if they were shown at
63+// least minShownCount times over more than maxAgeDays without the user acting
64+// on them.
6565 func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {
6666 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
6767
6868 _, err := e.db.ExecContext(ctx, `
69- INSERT OR IGNORE INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
69+ INSERT OR IGNORE INTO main.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
7070 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP
71- FROM recs.recommendation_impressions
71+ FROM main.recommendation_impressions
7272 WHERE acted = 0
7373 AND shown_count >= ?
7474 AND first_shown_at < ?
@@ -79,7 +79,7 @@ func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAge
7979 func (e *Engine) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {
8080 var count int
8181 err := e.db.QueryRowContext(ctx, `
82- SELECT COUNT(1) FROM recs.dismissed_recommendations
82+ SELECT COUNT(1) FROM main.dismissed_recommendations
8383 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?
8484 `, userDID, feedURL).Scan(&count)
8585 return count > 0, err
@@ -5,15 +5,15 @@ import (
5 "time"5 "time"
6 )6 )
7 7
8+// Impression records that a recommendation was shown to a user.
8 type Impression struct {9 type Impression struct {
9 TargetType string10 TargetType string
10 TargetID string11 TargetID string
11 }12 }
12 13
13 func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {14 func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {
14- return nil
15 _, err := e.db.ExecContext(ctx, `15 _, err := e.db.ExecContext(ctx, `
16- INSERT INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason)16+ INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)
17 VALUES (?, 'feed', ?, ?)17 VALUES (?, 'feed', ?, ?)
18 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP18 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
19 `, userDID, feedURL, reason)19 `, userDID, feedURL, reason)
@@ -21,9 +21,8 @@ func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason strin
21 }21 }
22 22
23 func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {23 func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {
24- return nil
25 _, err := e.db.ExecContext(ctx, `24 _, err := e.db.ExecContext(ctx, `
26- INSERT INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason)25+ INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason)
27 VALUES (?, 'article', ?, ?)26 VALUES (?, 'article', ?, ?)
28 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP27 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
29 `, userDID, articleURL, reason)28 `, userDID, articleURL, reason)
@@ -31,7 +30,6 @@ func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason
31 }30 }
32 31
33 func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {32 func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error {
34- return nil
35 tx, err := e.db.BeginTx(ctx, nil)33 tx, err := e.db.BeginTx(ctx, nil)
36 if err != nil {34 if err != nil {
37 return err35 return err
@@ -40,7 +38,7 @@ func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressi
40 38
41 for _, imp := range impressions {39 for _, imp := range impressions {
42 _, err := tx.ExecContext(ctx, `40 _, err := tx.ExecContext(ctx, `
43- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count)41+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count)
44 VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)42 VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
45 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET43 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET
46 last_shown_at = CURRENT_TIMESTAMP,44 last_shown_at = CURRENT_TIMESTAMP,
@@ -54,21 +52,23 @@ func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressi
54 }52 }
55 53
56 func (e *Engine) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {54 func (e *Engine) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {
57- return nil
58 _, err := e.db.ExecContext(ctx, `55 _, err := e.db.ExecContext(ctx, `
59- UPDATE recs.recommendation_impressions SET acted = 156+ UPDATE main.recommendation_impressions SET acted = 1
60 WHERE user_did = ? AND target_type = ? AND target_id = ?57 WHERE user_did = ? AND target_type = ? AND target_id = ?
61 `, userDID, targetType, targetID)58 `, userDID, targetType, targetID)
62 return err59 return err
63 }60 }
64 61
62+// AutoDismissStale marks recommendations as dismissed if they were shown at
63+// least minShownCount times over more than maxAgeDays without the user acting
64+// on them.
65 func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {65 func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAgeDays int) error {
66 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)66 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
67 67
68 _, err := e.db.ExecContext(ctx, `68 _, err := e.db.ExecContext(ctx, `
69- INSERT OR IGNORE INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)69+ INSERT OR IGNORE INTO main.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
70 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP70 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP
71- FROM recs.recommendation_impressions71+ FROM main.recommendation_impressions
72 WHERE acted = 072 WHERE acted = 0
73 AND shown_count >= ?73 AND shown_count >= ?
74 AND first_shown_at < ?74 AND first_shown_at < ?
@@ -79,7 +79,7 @@ func (e *Engine) AutoDismissStale(ctx context.Context, minShownCount int, maxAge
79 func (e *Engine) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {79 func (e *Engine) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {
80 var count int80 var count int
81 err := e.db.QueryRowContext(ctx, `81 err := e.db.QueryRowContext(ctx, `
82- SELECT COUNT(1) FROM recs.dismissed_recommendations82+ SELECT COUNT(1) FROM main.dismissed_recommendations
83 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?83 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?
84 `, userDID, feedURL).Scan(&count)84 `, userDID, feedURL).Scan(&count)
85 return count > 0, err85 return count > 0, err
modified internal/cluster/diversity.go +3 -0
@@ -8,6 +8,9 @@ import (
88 const maxPerDomain = 2
99 const maxPerCategory = 3
1010
11+// ApplyDiversity filters candidates to limit how many feeds come from the same
12+// domain (max 2) or category (max 3). Candidates are assumed to be sorted by
13+// score descending.
1114 func ApplyDiversity(candidates []*FeedRecommendation, topN int) []*FeedRecommendation {
1215 domainCount := make(map[string]int, len(candidates))
1316 categoryCount := make(map[string]int)
@@ -8,6 +8,9 @@ import (
8 const maxPerDomain = 28 const maxPerDomain = 2
9 const maxPerCategory = 39 const maxPerCategory = 3
10 10
11+// ApplyDiversity filters candidates to limit how many feeds come from the same
12+// domain (max 2) or category (max 3). Candidates are assumed to be sorted by
13+// score descending.
11 func ApplyDiversity(candidates []*FeedRecommendation, topN int) []*FeedRecommendation {14 func ApplyDiversity(candidates []*FeedRecommendation, topN int) []*FeedRecommendation {
12 domainCount := make(map[string]int, len(candidates))15 domainCount := make(map[string]int, len(candidates))
13 categoryCount := make(map[string]int)16 categoryCount := make(map[string]int)
added internal/cluster/embed.go +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+package cluster
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/binary"
7+
8+ "github.com/openai/openai-go"
9+ "github.com/openai/openai-go/option"
10+)
11+
12+// Embedder generates vector embeddings for text inputs. Implementations must be
13+// safe for concurrent use.
14+type Embedder interface {
15+ Embed(ctx context.Context, texts []string) ([][]float32, error)
16+ Dimension() int
17+}
18+
19+type OpenAIEmbedder struct {
20+ client openai.Client
21+ model string
22+ dimension int
23+}
24+
25+type OpenAIEmbedderConfig struct {
26+ BaseURL string
27+ APIKey string
28+ Model string
29+ Dimension int
30+}
31+
32+func NewOpenAIEmbedder(cfg OpenAIEmbedderConfig) *OpenAIEmbedder {
33+ opts := []option.RequestOption{}
34+ if cfg.BaseURL != "" {
35+ opts = append(opts, option.WithBaseURL(cfg.BaseURL))
36+ }
37+ if cfg.APIKey != "" {
38+ opts = append(opts, option.WithAPIKey(cfg.APIKey))
39+ }
40+ return &OpenAIEmbedder{
41+ client: openai.NewClient(opts...),
42+ model: cfg.Model,
43+ dimension: cfg.Dimension,
44+ }
45+}
46+
47+func (e *OpenAIEmbedder) Dimension() int {
48+ return e.dimension
49+}
50+
51+func (e *OpenAIEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
52+ resp, err := e.client.Embeddings.New(ctx, openai.EmbeddingNewParams{
53+ Model: e.model,
54+ Input: openai.EmbeddingNewParamsInputUnion{
55+ OfArrayOfStrings: texts,
56+ },
57+ })
58+ if err != nil {
59+ return nil, err
60+ }
61+
62+ embeddings := make([][]float32, len(resp.Data))
63+ for i, d := range resp.Data {
64+ embeddings[i] = make([]float32, len(d.Embedding))
65+ for j, v := range d.Embedding {
66+ embeddings[i][j] = float32(v)
67+ }
68+ }
69+ return embeddings, nil
70+}
71+
72+func deserializeFloat32(data []byte) []float32 {
73+ if len(data)%4 != 0 {
74+ return nil
75+ }
76+ result := make([]float32, len(data)/4)
77+ r := bytes.NewReader(data)
78+ _ = binary.Read(r, binary.LittleEndian, &result)
79+ return result
80+}
new file mode 100644
@@ -0,0 +1,80 @@
1+package cluster
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/binary"
7+
8+ "github.com/openai/openai-go"
9+ "github.com/openai/openai-go/option"
10+)
11+
12+// Embedder generates vector embeddings for text inputs. Implementations must be
13+// safe for concurrent use.
14+type Embedder interface {
15+ Embed(ctx context.Context, texts []string) ([][]float32, error)
16+ Dimension() int
17+}
18+
19+type OpenAIEmbedder struct {
20+ client openai.Client
21+ model string
22+ dimension int
23+}
24+
25+type OpenAIEmbedderConfig struct {
26+ BaseURL string
27+ APIKey string
28+ Model string
29+ Dimension int
30+}
31+
32+func NewOpenAIEmbedder(cfg OpenAIEmbedderConfig) *OpenAIEmbedder {
33+ opts := []option.RequestOption{}
34+ if cfg.BaseURL != "" {
35+ opts = append(opts, option.WithBaseURL(cfg.BaseURL))
36+ }
37+ if cfg.APIKey != "" {
38+ opts = append(opts, option.WithAPIKey(cfg.APIKey))
39+ }
40+ return &OpenAIEmbedder{
41+ client: openai.NewClient(opts...),
42+ model: cfg.Model,
43+ dimension: cfg.Dimension,
44+ }
45+}
46+
47+func (e *OpenAIEmbedder) Dimension() int {
48+ return e.dimension
49+}
50+
51+func (e *OpenAIEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
52+ resp, err := e.client.Embeddings.New(ctx, openai.EmbeddingNewParams{
53+ Model: e.model,
54+ Input: openai.EmbeddingNewParamsInputUnion{
55+ OfArrayOfStrings: texts,
56+ },
57+ })
58+ if err != nil {
59+ return nil, err
60+ }
61+
62+ embeddings := make([][]float32, len(resp.Data))
63+ for i, d := range resp.Data {
64+ embeddings[i] = make([]float32, len(d.Embedding))
65+ for j, v := range d.Embedding {
66+ embeddings[i][j] = float32(v)
67+ }
68+ }
69+ return embeddings, nil
70+}
71+
72+func deserializeFloat32(data []byte) []float32 {
73+ if len(data)%4 != 0 {
74+ return nil
75+ }
76+ result := make([]float32, len(data)/4)
77+ r := bytes.NewReader(data)
78+ _ = binary.Read(r, binary.LittleEndian, &result)
79+ return result
80+}
modified internal/cluster/jaccard.go +378 -298
@@ -5,9 +5,13 @@ import (
55 "database/sql"
66 "fmt"
77 "log/slog"
8+ "math"
89 "sync"
910 )
1011
12+// Config controls weights used during similarity computation (feed similarity
13+// embedding boost, user similarity like/tag/follow contributions). These are
14+// distinct from SignalWeights which control on-demand scoring multipliers.
1115 type Config struct {
1216 FollowBoost float64
1317 LikesWeight float64
@@ -15,6 +19,7 @@ type Config struct {
1519 DescriptionWeight float64
1620 }
1721
22+// DefaultConfig returns the default similarity computation weights.
1823 func DefaultConfig() Config {
1924 return Config{
2025 FollowBoost: 0.5,
@@ -24,348 +29,423 @@ func DefaultConfig() Config {
2429 }
2530 }
2631
27-// Engine uses *sql.DB directly because it performs cross-schema transactions
28-// across main, articles, and recs. Typed stores would add overhead without benefit here.
32+// Engine is the recommendation engine. It holds a reference to the SQLite
33+// database, an optional embedder for content-based signals, and configuration.
34+// All public methods are safe for concurrent use (cron writes, on-demand reads).
2935 type Engine struct {
30- db *sql.DB
31- logger *slog.Logger
32- mu sync.Mutex
33- config Config
36+ db *sql.DB
37+ logger *slog.Logger
38+ mu sync.Mutex
39+ config Config
40+ embedder Embedder
3441 }
3542
36-func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
37- return &Engine{db: db, logger: logger, config: DefaultConfig()}
43+// NewEngine creates a new recommendation engine. Pass nil for embedder to
44+// disable content-based signals (no embedding computation, no KNN queries).
45+func NewEngine(db *sql.DB, embedder Embedder, logger *slog.Logger) *Engine {
46+ return &Engine{db: db, logger: logger, config: DefaultConfig(), embedder: embedder}
3847 }
3948
49+// ComputeFeedSimilarity recomputes the feed_similarity table: time-decayed
50+// subscriber Jaccard for all feed pairs with shared subscribers, plus an
51+// embedding cosine similarity boost for pairs that both have embeddings.
4052 func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
41- tx, err := e.db.BeginTx(ctx, nil)
53+ conn, err := e.db.Conn(ctx)
4254 if err != nil {
4355 return err
4456 }
45- defer func() { _ = tx.Rollback() }()
57+ defer conn.Close()
4658
47- if _, err := tx.ExecContext(ctx, `DELETE FROM recs.feed_similarity`); err != nil {
48- return err
49- }
59+ {
60+ tx, err := conn.BeginTx(ctx, nil)
61+ if err != nil {
62+ return err
63+ }
64+ defer func() { _ = tx.Rollback() }()
5065
51- _, err = tx.ExecContext(ctx, `
52- INSERT INTO recs.feed_similarity (feed_a, feed_b, jaccard)
53- SELECT
54- s1.feed_url,
55- s2.feed_url,
56- CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))
57- FROM articles.subscriptions s1
58- JOIN articles.subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
59- JOIN articles.feeds f1 ON f1.feed_url = s1.feed_url
60- JOIN articles.feeds f2 ON f2.feed_url = s2.feed_url
61- GROUP BY s1.feed_url, s2.feed_url
62- `)
63- if err != nil {
64- return err
65- }
66+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _feed_sim_staging (
67+ feed_a TEXT NOT NULL, feed_b TEXT NOT NULL, jaccard REAL NOT NULL,
68+ PRIMARY KEY (feed_a, feed_b))`); err != nil {
69+ return err
70+ }
71+ if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_sim_staging`); err != nil {
72+ return err
73+ }
6674
67- if err := e.computeDescriptionSimilarity(ctx, tx); err != nil {
68- e.logger.Warn("description similarity failed", "error", err)
75+ _, err = tx.ExecContext(ctx, `
76+ INSERT INTO _feed_sim_staging (feed_a, feed_b, jaccard)
77+ SELECT
78+ s1.feed_url,
79+ s2.feed_url,
80+ SUM(EXP(-0.023 * CAST(julianday('now') - julianday(MIN(s1.added_at, s2.added_at)) AS REAL)))
81+ / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))
82+ FROM articles.subscriptions s1
83+ JOIN articles.subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
84+ JOIN articles.feeds f1 ON f1.feed_url = s1.feed_url
85+ JOIN articles.feeds f2 ON f2.feed_url = s2.feed_url
86+ WHERE s1.added_at IS NOT NULL AND s2.added_at IS NOT NULL
87+ GROUP BY s1.feed_url, s2.feed_url
88+ `)
89+ if err != nil {
90+ return err
91+ }
92+
93+ if err := e.computeEmbeddingSimilarity(ctx, tx); err != nil {
94+ e.logger.Warn("embedding similarity failed", "error", err)
95+ }
96+
97+ if err := tx.Commit(); err != nil {
98+ return err
99+ }
100+ }
101+
102+ {
103+ tx, err := conn.BeginTx(ctx, nil)
104+ if err != nil {
105+ return err
106+ }
107+ defer func() { _ = tx.Rollback() }()
108+
109+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.feed_similarity`); err != nil {
110+ return err
111+ }
112+ if _, err := tx.ExecContext(ctx, `INSERT INTO recs.feed_similarity (feed_a, feed_b, jaccard) SELECT feed_a, feed_b, jaccard FROM _feed_sim_staging`); err != nil {
113+ return err
114+ }
115+
116+ e.logger.Info("feed similarity computed")
117+ return tx.Commit()
69118 }
70-
71- e.logger.Info("feed similarity computed")
72- return tx.Commit()
73119 }
74120
75-func (e *Engine) computeDescriptionSimilarity(ctx context.Context, tx *sql.Tx) error {
76- if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _feed_words (feed_url TEXT, word TEXT)`); err != nil {
77- return err
78- }
79- if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_words`); err != nil {
80- return err
121+func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) error {
122+ if e.embedder == nil {
123+ return nil
81124 }
125+ e.logger.Debug("computing embedding similarity")
82126
83- _, err := tx.ExecContext(ctx, `
84- INSERT INTO _feed_words (feed_url, word)
85- WITH feed_tokens AS (
86- SELECT feed_url, LOWER(TRIM(value)) AS word
87- FROM articles.feeds,
88- json_each('["' || REPLACE(LOWER(COALESCE(description, '')), ' ', '","') || '"]')
89- WHERE description IS NOT NULL AND description != ''
90- )
91- SELECT feed_url, word FROM feed_tokens
92- WHERE LENGTH(word) > 3
93- AND word NOT IN ('about','also','been','being','both','could','every','from','have','here',
94- 'into','just','like','more','much','must','other','over','some','such','than','that',
95- 'their','them','then','there','these','they','this','through','very','what','when',
96- 'where','which','while','will','with','your','most','updated','latest','posts',
97- 'news','blog','feed','reading','read','articles','article','weekly','daily',
98- 'monthly','personal','thoughts','views','opinions','writing','write','written')
99- `)
127+ stagingRows, err := tx.QueryContext(ctx, `SELECT feed_a, feed_b FROM _feed_sim_staging`)
100128 if err != nil {
101129 return err
102130 }
103131
104- if _, err := tx.ExecContext(ctx, `
105- CREATE TEMP TABLE IF NOT EXISTS _feed_word_counts (feed_url TEXT PRIMARY KEY, cnt INT)
106- `); err != nil {
107- return err
108- }
109- if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_word_counts`); err != nil {
110- return err
111- }
112- if _, err := tx.ExecContext(ctx, `
113- INSERT INTO _feed_word_counts (feed_url, cnt)
114- SELECT feed_url, COUNT(DISTINCT word) FROM _feed_words GROUP BY feed_url
115- `); err != nil {
116- return err
132+ type pair struct{ a, b string }
133+ var pairs []pair
134+ feedSet := make(map[string]bool)
135+ for stagingRows.Next() {
136+ var p pair
137+ if err := stagingRows.Scan(&p.a, &p.b); err != nil {
138+ stagingRows.Close()
139+ return err
140+ }
141+ pairs = append(pairs, p)
142+ feedSet[p.a] = true
143+ feedSet[p.b] = true
117144 }
145+ stagingRows.Close()
118146
119- if _, err := tx.ExecContext(ctx, `
120- CREATE TEMP TABLE IF NOT EXISTS _word_overlap (feed_a TEXT, feed_b TEXT, common INT)
121- `); err != nil {
122- return err
123- }
124- if _, err := tx.ExecContext(ctx, `DELETE FROM _word_overlap`); err != nil {
125- return err
126- }
127- if _, err := tx.ExecContext(ctx, `
128- INSERT INTO _word_overlap (feed_a, feed_b, common)
129- SELECT w1.feed_url, w2.feed_url, COUNT(DISTINCT w1.word)
130- FROM _feed_words w1
131- JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
132- GROUP BY w1.feed_url, w2.feed_url
133- HAVING COUNT(DISTINCT w1.word) > 1
134- `); err != nil {
135- return err
147+ if len(feedSet) == 0 {
148+ return nil
136149 }
137150
138- if _, err := tx.ExecContext(ctx, `
139- CREATE INDEX IF NOT EXISTS _idx_feed_words_word ON _feed_words(word, feed_url)
140- `); err != nil {
141- return err
151+ ph := make([]string, 0, len(feedSet))
152+ args := make([]any, 0, len(feedSet))
153+ for url := range feedSet {
154+ ph = append(ph, "?")
155+ args = append(args, url)
142156 }
143-
144- descInsert := `
145- INSERT OR IGNORE INTO recs.feed_similarity (feed_a, feed_b, jaccard)
146- SELECT feed_a, feed_b, 0 FROM _word_overlap
147- `
148- if _, err := tx.ExecContext(ctx, descInsert); err != nil {
157+ embRows, err := tx.QueryContext(ctx,
158+ fmt.Sprintf("SELECT feed_url, embedding FROM recs.feed_embeddings WHERE feed_url IN (%s)", joinPh(ph)),
159+ args...,
160+ )
161+ if err != nil {
149162 return err
150163 }
151164
152- descUpdate := fmt.Sprintf(`
153- UPDATE recs.feed_similarity SET
154- jaccard = jaccard + %g * CAST(_word_overlap.common AS REAL) / NULLIF(
155- (SELECT cnt FROM _feed_word_counts WHERE feed_url = recs.feed_similarity.feed_a) +
156- (SELECT cnt FROM _feed_word_counts WHERE feed_url = recs.feed_similarity.feed_b) -
157- CAST(_word_overlap.common AS REAL),
158- 0
159- )
160- FROM _word_overlap
161- WHERE recs.feed_similarity.feed_a = _word_overlap.feed_a
162- AND recs.feed_similarity.feed_b = _word_overlap.feed_b
163- `, e.config.DescriptionWeight)
164-
165- if _, err := tx.ExecContext(ctx, descUpdate); err != nil {
166- return err
165+ embeddings := make(map[string][]float32)
166+ for embRows.Next() {
167+ var url string
168+ var blob []byte
169+ if err := embRows.Scan(&url, &blob); err != nil {
170+ embRows.Close()
171+ return err
172+ }
173+ v := deserializeFloat32(blob)
174+ if len(v) > 0 {
175+ embeddings[url] = v
176+ }
177+ }
178+ embRows.Close()
179+
180+ for _, p := range pairs {
181+ vecA, okA := embeddings[p.a]
182+ vecB, okB := embeddings[p.b]
183+ if !okA || !okB {
184+ continue
185+ }
186+ sim := cosineSimilarity(vecA, vecB)
187+ if sim <= 0 {
188+ continue
189+ }
190+ boost := sim * e.config.DescriptionWeight
191+ if _, err := tx.ExecContext(ctx,
192+ `UPDATE _feed_sim_staging SET jaccard = jaccard + ? WHERE feed_a = ? AND feed_b = ?`,
193+ boost, p.a, p.b,
194+ ); err != nil {
195+ return err
196+ }
167197 }
168198
169199 return nil
170200 }
171201
172-func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
173- tx, err := e.db.BeginTx(ctx, nil)
174- if err != nil {
175- return err
202+func cosineSimilarity(a, b []float32) float64 {
203+ var dot, normA, normB float64
204+ for i := range a {
205+ dot += float64(a[i]) * float64(b[i])
206+ normA += float64(a[i]) * float64(a[i])
207+ normB += float64(b[i]) * float64(b[i])
176208 }
177- defer func() { _ = tx.Rollback() }()
178-
179- if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_similarity`); err != nil {
180- return err
209+ if normA == 0 || normB == 0 {
210+ return 0
181211 }
212+ return dot / (math.Sqrt(normA) * math.Sqrt(normB))
213+}
182214
183- _, err = tx.ExecContext(ctx, `
184- INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds)
185- SELECT
186- s1.user_did,
187- s2.user_did,
188- CAST(COUNT(*) AS REAL) / (
189- (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s1.user_did) +
190- (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s2.user_did) -
191- CAST(COUNT(*) AS REAL)
192- ),
193- COUNT(*)
194- FROM articles.subscriptions s1
195- JOIN articles.subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
196- GROUP BY s1.user_did, s2.user_did
197- `)
215+// ComputeUserSimilarity recomputes the user_similarity table: subscription
216+// Jaccard + time-decayed like co-occurrence + tag overlap + follow boost.
217+func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
218+ conn, err := e.db.Conn(ctx)
198219 if err != nil {
199220 return err
200221 }
222+ defer conn.Close()
201223
202- if _, err := tx.ExecContext(ctx, `
203- CREATE TEMP TABLE IF NOT EXISTS _likes_count (author_did TEXT PRIMARY KEY, cnt INT)
204- `); err != nil {
205- return err
206- }
207- if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_count`); err != nil {
208- return err
209- }
210- if _, err := tx.ExecContext(ctx, `
211- INSERT INTO _likes_count (author_did, cnt)
212- SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
213- `); err != nil {
214- return err
215- }
216-
217- if _, err := tx.ExecContext(ctx, `
218- CREATE TEMP TABLE IF NOT EXISTS _likes_overlap (user_a TEXT, user_b TEXT, common INT, PRIMARY KEY(user_a, user_b))
219- `); err != nil {
220- return err
221- }
222- if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_overlap`); err != nil {
223- return err
224- }
225- if _, err := tx.ExecContext(ctx, `
226- INSERT INTO _likes_overlap (user_a, user_b, common)
227- SELECT l1.author_did, l2.author_did,
228- CAST(SUM(
229- EXP(-0.023 * CAST(julianday('now') - julianday(l1.created_at) AS REAL))
230- * EXP(-0.023 * CAST(julianday('now') - julianday(l2.created_at) AS REAL))
231- ) AS INTEGER)
232- FROM articles.likes l1
233- JOIN articles.likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
234- AND l1.author_did < l2.author_did
235- WHERE l1.created_at IS NOT NULL AND l2.created_at IS NOT NULL
236- GROUP BY l1.author_did, l2.author_did
237- `); err != nil {
238- return err
239- }
224+ {
225+ tx, err := conn.BeginTx(ctx, nil)
226+ if err != nil {
227+ return err
228+ }
229+ defer func() { _ = tx.Rollback() }()
240230
241- likesUpdate := fmt.Sprintf(`
242- UPDATE recs.user_similarity SET
243- jaccard = jaccard + %g * CAST(_likes_overlap.common AS REAL) / NULLIF(
244- (SELECT cnt FROM _likes_count WHERE author_did = recs.user_similarity.user_a) +
245- (SELECT cnt FROM _likes_count WHERE author_did = recs.user_similarity.user_b) -
246- CAST(_likes_overlap.common AS REAL),
247- 0
248- ),
249- common_likes = _likes_overlap.common
250- FROM _likes_overlap
251- WHERE recs.user_similarity.user_a = _likes_overlap.user_a
252- AND recs.user_similarity.user_b = _likes_overlap.user_b
253- `, e.config.LikesWeight)
254-
255- if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
256- return err
257- }
231+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _user_sim_staging (
232+ user_a TEXT NOT NULL, user_b TEXT NOT NULL, jaccard REAL NOT NULL,
233+ common_feeds INT NOT NULL DEFAULT 0, common_likes INT NOT NULL DEFAULT 0, common_tags INT NOT NULL DEFAULT 0,
234+ PRIMARY KEY (user_a, user_b))`); err != nil {
235+ return err
236+ }
237+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_sim_staging`); err != nil {
238+ return err
239+ }
258240
259- likesInsert := fmt.Sprintf(`
260- INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
261- SELECT sub.user_a, sub.user_b, sub.jaccard, 0, sub.common
262- FROM (
241+ _, err = tx.ExecContext(ctx, `
242+ INSERT INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
263243 SELECT
264- lo.user_a,
265- lo.user_b,
266- %g * CAST(lo.common AS REAL) / NULLIF(
267- (SELECT cnt FROM _likes_count WHERE author_did = lo.user_a) +
268- (SELECT cnt FROM _likes_count WHERE author_did = lo.user_b) -
269- CAST(lo.common AS REAL),
270- 0
271- ) AS jaccard,
272- lo.common
273- FROM _likes_overlap lo
274- ) sub WHERE 1
275- ON CONFLICT(user_a, user_b) DO UPDATE SET
276- jaccard = jaccard + excluded.jaccard,
277- common_likes = excluded.common_likes
278- `, e.config.LikesWeight)
279-
280- if _, err := tx.ExecContext(ctx, likesInsert); err != nil {
281- return err
282- }
283-
284- if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _tag_overlap (user_a TEXT, user_b TEXT, common INT)`); err != nil {
285- return err
286- }
287- if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_overlap`); err != nil {
288- return err
289- }
290-
291- _, err = tx.ExecContext(ctx, `
292- INSERT INTO _tag_overlap (user_a, user_b, common)
293- WITH user_tags AS (
294- SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
295- WHERE tags IS NOT NULL AND tags != ''
296- )
297- SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
298- FROM user_tags t1
299- JOIN user_tags t2 ON t1.tag = t2.tag AND t1.author_did < t2.author_did
300- GROUP BY t1.author_did, t2.author_did
301- `)
302- if err != nil {
303- return err
304- }
305-
306- if _, err := tx.ExecContext(ctx, `
307- CREATE TEMP TABLE IF NOT EXISTS _tag_count (author_did TEXT PRIMARY KEY, cnt INT)
308- `); err != nil {
309- return err
310- }
311- if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_count`); err != nil {
312- return err
313- }
314- if _, err := tx.ExecContext(ctx, `
315- INSERT INTO _tag_count (author_did, cnt)
316- WITH user_tags AS (
317- SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
318- WHERE tags IS NOT NULL AND tags != ''
319- )
320- SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
321- `); err != nil {
322- return err
323- }
324-
325- _, err = tx.ExecContext(ctx, `
326- INSERT OR IGNORE INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
327- SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
328- `)
329- if err != nil {
330- return err
331- }
332-
333- tagsUpdate := fmt.Sprintf(`
334- UPDATE recs.user_similarity SET
335- jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
336- (SELECT cnt FROM _tag_count WHERE author_did = recs.user_similarity.user_a) +
337- (SELECT cnt FROM _tag_count WHERE author_did = recs.user_similarity.user_b) -
338- CAST(_tag_overlap.common AS REAL),
244+ s1.user_did,
245+ s2.user_did,
246+ CAST(COUNT(*) AS REAL) / (
247+ (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s1.user_did) +
248+ (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s2.user_did) -
249+ CAST(COUNT(*) AS REAL)
250+ ),
251+ COUNT(*),
252+ 0,
339253 0
340- ),
341- common_tags = _tag_overlap.common
342- FROM _tag_overlap
343- WHERE recs.user_similarity.user_a = _tag_overlap.user_a
344- AND recs.user_similarity.user_b = _tag_overlap.user_b
345- `, e.config.TagsWeight)
346-
347- if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {
348- return err
349- }
350-
351- followQuery := fmt.Sprintf(`
352- INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
353- SELECT
354- MIN(f.user_did, f.target_did),
355- MAX(f.user_did, f.target_did),
356- %g,
357- 0, 0, 0
358- FROM main.follows f
359- WHERE f.user_did != f.target_did
360- GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)
361- ON CONFLICT(user_a, user_b) DO UPDATE SET
362- jaccard = jaccard + %g
363- `, e.config.FollowBoost, e.config.FollowBoost)
364-
365- if _, err := tx.ExecContext(ctx, followQuery); err != nil {
366- return err
254+ FROM articles.subscriptions s1
255+ JOIN articles.subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
256+ GROUP BY s1.user_did, s2.user_did
257+ `)
258+ if err != nil {
259+ return err
260+ }
261+
262+ if _, err := tx.ExecContext(ctx, `
263+ CREATE TEMP TABLE IF NOT EXISTS _likes_count (author_did TEXT PRIMARY KEY, cnt INT)
264+ `); err != nil {
265+ return err
266+ }
267+ if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_count`); err != nil {
268+ return err
269+ }
270+ if _, err := tx.ExecContext(ctx, `
271+ INSERT INTO _likes_count (author_did, cnt)
272+ SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
273+ `); err != nil {
274+ return err
275+ }
276+
277+ if _, err := tx.ExecContext(ctx, `
278+ CREATE TEMP TABLE IF NOT EXISTS _likes_overlap (user_a TEXT, user_b TEXT, common INT, PRIMARY KEY(user_a, user_b))
279+ `); err != nil {
280+ return err
281+ }
282+ if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_overlap`); err != nil {
283+ return err
284+ }
285+ if _, err := tx.ExecContext(ctx, `
286+ INSERT INTO _likes_overlap (user_a, user_b, common)
287+ SELECT l1.author_did, l2.author_did,
288+ CAST(SUM(
289+ EXP(-0.023 * CAST(julianday('now') - julianday(l1.created_at) AS REAL))
290+ * EXP(-0.023 * CAST(julianday('now') - julianday(l2.created_at) AS REAL))
291+ ) AS INTEGER)
292+ FROM articles.likes l1
293+ JOIN articles.likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
294+ AND l1.author_did < l2.author_did
295+ WHERE l1.created_at IS NOT NULL AND l2.created_at IS NOT NULL
296+ GROUP BY l1.author_did, l2.author_did
297+ `); err != nil {
298+ return err
299+ }
300+
301+ likesUpdate := fmt.Sprintf(`
302+ UPDATE _user_sim_staging SET
303+ jaccard = jaccard + %g * CAST(_likes_overlap.common AS REAL) / NULLIF(
304+ (SELECT cnt FROM _likes_count WHERE author_did = _user_sim_staging.user_a) +
305+ (SELECT cnt FROM _likes_count WHERE author_did = _user_sim_staging.user_b) -
306+ CAST(_likes_overlap.common AS REAL),
307+ 0
308+ ),
309+ common_likes = _likes_overlap.common
310+ FROM _likes_overlap
311+ WHERE _user_sim_staging.user_a = _likes_overlap.user_a
312+ AND _user_sim_staging.user_b = _likes_overlap.user_b
313+ `, e.config.LikesWeight)
314+
315+ if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
316+ return err
317+ }
318+
319+ likesInsert := fmt.Sprintf(`
320+ INSERT INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_likes)
321+ SELECT sub.user_a, sub.user_b, sub.jaccard, 0, sub.common
322+ FROM (
323+ SELECT
324+ lo.user_a,
325+ lo.user_b,
326+ %g * CAST(lo.common AS REAL) / NULLIF(
327+ (SELECT cnt FROM _likes_count WHERE author_did = lo.user_a) +
328+ (SELECT cnt FROM _likes_count WHERE author_did = lo.user_b) -
329+ CAST(lo.common AS REAL),
330+ 0
331+ ) AS jaccard,
332+ lo.common
333+ FROM _likes_overlap lo
334+ ) sub WHERE 1
335+ ON CONFLICT(user_a, user_b) DO UPDATE SET
336+ jaccard = jaccard + excluded.jaccard,
337+ common_likes = excluded.common_likes
338+ `, e.config.LikesWeight)
339+
340+ if _, err := tx.ExecContext(ctx, likesInsert); err != nil {
341+ return err
342+ }
343+
344+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _tag_overlap (user_a TEXT, user_b TEXT, common INT)`); err != nil {
345+ return err
346+ }
347+ if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_overlap`); err != nil {
348+ return err
349+ }
350+
351+ _, err = tx.ExecContext(ctx, `
352+ INSERT INTO _tag_overlap (user_a, user_b, common)
353+ WITH user_tags AS (
354+ SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
355+ WHERE tags IS NOT NULL AND tags != ''
356+ )
357+ SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
358+ FROM user_tags t1
359+ JOIN user_tags t2 ON t1.tag = t2.tag AND t1.author_did < t2.author_did
360+ GROUP BY t1.author_did, t2.author_did
361+ `)
362+ if err != nil {
363+ return err
364+ }
365+
366+ if _, err := tx.ExecContext(ctx, `
367+ CREATE TEMP TABLE IF NOT EXISTS _tag_count (author_did TEXT PRIMARY KEY, cnt INT)
368+ `); err != nil {
369+ return err
370+ }
371+ if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_count`); err != nil {
372+ return err
373+ }
374+ if _, err := tx.ExecContext(ctx, `
375+ INSERT INTO _tag_count (author_did, cnt)
376+ WITH user_tags AS (
377+ SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
378+ WHERE tags IS NOT NULL AND tags != ''
379+ )
380+ SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
381+ `); err != nil {
382+ return err
383+ }
384+
385+ _, err = tx.ExecContext(ctx, `
386+ INSERT OR IGNORE INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_tags)
387+ SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
388+ `)
389+ if err != nil {
390+ return err
391+ }
392+
393+ tagsUpdate := fmt.Sprintf(`
394+ UPDATE _user_sim_staging SET
395+ jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
396+ (SELECT cnt FROM _tag_count WHERE author_did = _user_sim_staging.user_a) +
397+ (SELECT cnt FROM _tag_count WHERE author_did = _user_sim_staging.user_b) -
398+ CAST(_tag_overlap.common AS REAL),
399+ 0
400+ ),
401+ common_tags = _tag_overlap.common
402+ FROM _tag_overlap
403+ WHERE _user_sim_staging.user_a = _tag_overlap.user_a
404+ AND _user_sim_staging.user_b = _tag_overlap.user_b
405+ `, e.config.TagsWeight)
406+
407+ if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {
408+ return err
409+ }
410+
411+ followQuery := fmt.Sprintf(`
412+ INSERT INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
413+ SELECT
414+ MIN(f.user_did, f.target_did),
415+ MAX(f.user_did, f.target_did),
416+ %g,
417+ 0, 0, 0
418+ FROM main.follows f
419+ WHERE f.user_did != f.target_did
420+ GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)
421+ ON CONFLICT(user_a, user_b) DO UPDATE SET
422+ jaccard = jaccard + %g
423+ `, e.config.FollowBoost, e.config.FollowBoost)
424+
425+ if _, err := tx.ExecContext(ctx, followQuery); err != nil {
426+ return err
427+ }
428+
429+ if err := tx.Commit(); err != nil {
430+ return err
431+ }
432+ }
433+
434+ {
435+ tx, err := conn.BeginTx(ctx, nil)
436+ if err != nil {
437+ return err
438+ }
439+ defer func() { _ = tx.Rollback() }()
440+
441+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_similarity`); err != nil {
442+ return err
443+ }
444+ if _, err := tx.ExecContext(ctx, `INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags) SELECT user_a, user_b, jaccard, common_feeds, common_likes, common_tags FROM _user_sim_staging`); err != nil {
445+ return err
446+ }
447+
448+ e.logger.Info("user similarity computed")
449+ return tx.Commit()
367450 }
368-
369- e.logger.Info("user similarity computed")
370- return tx.Commit()
371451 }
@@ -5,9 +5,13 @@ import (
5 "database/sql"5 "database/sql"
6 "fmt"6 "fmt"
7 "log/slog"7 "log/slog"
8+ "math"
8 "sync"9 "sync"
9 )10 )
10 11
12+// Config controls weights used during similarity computation (feed similarity
13+// embedding boost, user similarity like/tag/follow contributions). These are
14+// distinct from SignalWeights which control on-demand scoring multipliers.
11 type Config struct {15 type Config struct {
12 FollowBoost float6416 FollowBoost float64
13 LikesWeight float6417 LikesWeight float64
@@ -15,6 +19,7 @@ type Config struct {
15 DescriptionWeight float6419 DescriptionWeight float64
16 }20 }
17 21
22+// DefaultConfig returns the default similarity computation weights.
18 func DefaultConfig() Config {23 func DefaultConfig() Config {
19 return Config{24 return Config{
20 FollowBoost: 0.5,25 FollowBoost: 0.5,
@@ -24,348 +29,423 @@ func DefaultConfig() Config {
24 }29 }
25 }30 }
26 31
27-// Engine uses *sql.DB directly because it performs cross-schema transactions32+// Engine is the recommendation engine. It holds a reference to the SQLite
28-// across main, articles, and recs. Typed stores would add overhead without benefit here.33+// database, an optional embedder for content-based signals, and configuration.
34+// All public methods are safe for concurrent use (cron writes, on-demand reads).
29 type Engine struct {35 type Engine struct {
30- db *sql.DB36+ db *sql.DB
31- logger *slog.Logger37+ logger *slog.Logger
32- mu sync.Mutex38+ mu sync.Mutex
33- config Config39+ config Config
40+ embedder Embedder
34 }41 }
35 42
36-func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {43+// NewEngine creates a new recommendation engine. Pass nil for embedder to
37- return &Engine{db: db, logger: logger, config: DefaultConfig()}44+// disable content-based signals (no embedding computation, no KNN queries).
45+func NewEngine(db *sql.DB, embedder Embedder, logger *slog.Logger) *Engine {
46+ return &Engine{db: db, logger: logger, config: DefaultConfig(), embedder: embedder}
38 }47 }
39 48
49+// ComputeFeedSimilarity recomputes the feed_similarity table: time-decayed
50+// subscriber Jaccard for all feed pairs with shared subscribers, plus an
51+// embedding cosine similarity boost for pairs that both have embeddings.
40 func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {52 func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
41- tx, err := e.db.BeginTx(ctx, nil)53+ conn, err := e.db.Conn(ctx)
42 if err != nil {54 if err != nil {
43 return err55 return err
44 }56 }
45- defer func() { _ = tx.Rollback() }()57+ defer conn.Close()
46 58
47- if _, err := tx.ExecContext(ctx, `DELETE FROM recs.feed_similarity`); err != nil {59+ {
48- return err60+ tx, err := conn.BeginTx(ctx, nil)
49- }61+ if err != nil {
62+ return err
63+ }
64+ defer func() { _ = tx.Rollback() }()
50 65
51- _, err = tx.ExecContext(ctx, `66+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _feed_sim_staging (
52- INSERT INTO recs.feed_similarity (feed_a, feed_b, jaccard)67+ feed_a TEXT NOT NULL, feed_b TEXT NOT NULL, jaccard REAL NOT NULL,
53- SELECT68+ PRIMARY KEY (feed_a, feed_b))`); err != nil {
54- s1.feed_url,69+ return err
55- s2.feed_url,70+ }
56- CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))71+ if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_sim_staging`); err != nil {
57- FROM articles.subscriptions s172+ return err
58- JOIN articles.subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url73+ }
59- JOIN articles.feeds f1 ON f1.feed_url = s1.feed_url
60- JOIN articles.feeds f2 ON f2.feed_url = s2.feed_url
61- GROUP BY s1.feed_url, s2.feed_url
62- `)
63- if err != nil {
64- return err
65- }
66 74
67- if err := e.computeDescriptionSimilarity(ctx, tx); err != nil {75+ _, err = tx.ExecContext(ctx, `
68- e.logger.Warn("description similarity failed", "error", err)76+ INSERT INTO _feed_sim_staging (feed_a, feed_b, jaccard)
77+ SELECT
78+ s1.feed_url,
79+ s2.feed_url,
80+ SUM(EXP(-0.023 * CAST(julianday('now') - julianday(MIN(s1.added_at, s2.added_at)) AS REAL)))
81+ / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))
82+ FROM articles.subscriptions s1
83+ JOIN articles.subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
84+ JOIN articles.feeds f1 ON f1.feed_url = s1.feed_url
85+ JOIN articles.feeds f2 ON f2.feed_url = s2.feed_url
86+ WHERE s1.added_at IS NOT NULL AND s2.added_at IS NOT NULL
87+ GROUP BY s1.feed_url, s2.feed_url
88+ `)
89+ if err != nil {
90+ return err
91+ }
92+
93+ if err := e.computeEmbeddingSimilarity(ctx, tx); err != nil {
94+ e.logger.Warn("embedding similarity failed", "error", err)
95+ }
96+
97+ if err := tx.Commit(); err != nil {
98+ return err
99+ }
100+ }
101+
102+ {
103+ tx, err := conn.BeginTx(ctx, nil)
104+ if err != nil {
105+ return err
106+ }
107+ defer func() { _ = tx.Rollback() }()
108+
109+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.feed_similarity`); err != nil {
110+ return err
111+ }
112+ if _, err := tx.ExecContext(ctx, `INSERT INTO recs.feed_similarity (feed_a, feed_b, jaccard) SELECT feed_a, feed_b, jaccard FROM _feed_sim_staging`); err != nil {
113+ return err
114+ }
115+
116+ e.logger.Info("feed similarity computed")
117+ return tx.Commit()
69 }118 }
70-
71- e.logger.Info("feed similarity computed")
72- return tx.Commit()
73 }119 }
74 120
75-func (e *Engine) computeDescriptionSimilarity(ctx context.Context, tx *sql.Tx) error {121+func (e *Engine) computeEmbeddingSimilarity(ctx context.Context, tx *sql.Tx) error {
76- if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _feed_words (feed_url TEXT, word TEXT)`); err != nil {122+ if e.embedder == nil {
77- return err123+ return nil
78- }
79- if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_words`); err != nil {
80- return err
81 }124 }
125+ e.logger.Debug("computing embedding similarity")
82 126
83- _, err := tx.ExecContext(ctx, `127+ stagingRows, err := tx.QueryContext(ctx, `SELECT feed_a, feed_b FROM _feed_sim_staging`)
84- INSERT INTO _feed_words (feed_url, word)
85- WITH feed_tokens AS (
86- SELECT feed_url, LOWER(TRIM(value)) AS word
87- FROM articles.feeds,
88- json_each('["' || REPLACE(LOWER(COALESCE(description, '')), ' ', '","') || '"]')
89- WHERE description IS NOT NULL AND description != ''
90- )
91- SELECT feed_url, word FROM feed_tokens
92- WHERE LENGTH(word) > 3
93- AND word NOT IN ('about','also','been','being','both','could','every','from','have','here',
94- 'into','just','like','more','much','must','other','over','some','such','than','that',
95- 'their','them','then','there','these','they','this','through','very','what','when',
96- 'where','which','while','will','with','your','most','updated','latest','posts',
97- 'news','blog','feed','reading','read','articles','article','weekly','daily',
98- 'monthly','personal','thoughts','views','opinions','writing','write','written')
99- `)
100 if err != nil {128 if err != nil {
101 return err129 return err
102 }130 }
103 131
104- if _, err := tx.ExecContext(ctx, `132+ type pair struct{ a, b string }
105- CREATE TEMP TABLE IF NOT EXISTS _feed_word_counts (feed_url TEXT PRIMARY KEY, cnt INT)133+ var pairs []pair
106- `); err != nil {134+ feedSet := make(map[string]bool)
107- return err135+ for stagingRows.Next() {
108- }136+ var p pair
109- if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_word_counts`); err != nil {137+ if err := stagingRows.Scan(&p.a, &p.b); err != nil {
110- return err138+ stagingRows.Close()
111- }139+ return err
112- if _, err := tx.ExecContext(ctx, `140+ }
113- INSERT INTO _feed_word_counts (feed_url, cnt)141+ pairs = append(pairs, p)
114- SELECT feed_url, COUNT(DISTINCT word) FROM _feed_words GROUP BY feed_url142+ feedSet[p.a] = true
115- `); err != nil {143+ feedSet[p.b] = true
116- return err
117 }144 }
145+ stagingRows.Close()
118 146
119- if _, err := tx.ExecContext(ctx, `147+ if len(feedSet) == 0 {
120- CREATE TEMP TABLE IF NOT EXISTS _word_overlap (feed_a TEXT, feed_b TEXT, common INT)148+ return nil
121- `); err != nil {
122- return err
123- }
124- if _, err := tx.ExecContext(ctx, `DELETE FROM _word_overlap`); err != nil {
125- return err
126- }
127- if _, err := tx.ExecContext(ctx, `
128- INSERT INTO _word_overlap (feed_a, feed_b, common)
129- SELECT w1.feed_url, w2.feed_url, COUNT(DISTINCT w1.word)
130- FROM _feed_words w1
131- JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
132- GROUP BY w1.feed_url, w2.feed_url
133- HAVING COUNT(DISTINCT w1.word) > 1
134- `); err != nil {
135- return err
136 }149 }
137 150
138- if _, err := tx.ExecContext(ctx, `151+ ph := make([]string, 0, len(feedSet))
139- CREATE INDEX IF NOT EXISTS _idx_feed_words_word ON _feed_words(word, feed_url)152+ args := make([]any, 0, len(feedSet))
140- `); err != nil {153+ for url := range feedSet {
141- return err154+ ph = append(ph, "?")
155+ args = append(args, url)
142 }156 }
143-157+ embRows, err := tx.QueryContext(ctx,
144- descInsert := `158+ fmt.Sprintf("SELECT feed_url, embedding FROM recs.feed_embeddings WHERE feed_url IN (%s)", joinPh(ph)),
145- INSERT OR IGNORE INTO recs.feed_similarity (feed_a, feed_b, jaccard)159+ args...,
146- SELECT feed_a, feed_b, 0 FROM _word_overlap160+ )
147- `161+ if err != nil {
148- if _, err := tx.ExecContext(ctx, descInsert); err != nil {
149 return err162 return err
150 }163 }
151 164
152- descUpdate := fmt.Sprintf(`165+ embeddings := make(map[string][]float32)
153- UPDATE recs.feed_similarity SET166+ for embRows.Next() {
154- jaccard = jaccard + %g * CAST(_word_overlap.common AS REAL) / NULLIF(167+ var url string
155- (SELECT cnt FROM _feed_word_counts WHERE feed_url = recs.feed_similarity.feed_a) +168+ var blob []byte
156- (SELECT cnt FROM _feed_word_counts WHERE feed_url = recs.feed_similarity.feed_b) -169+ if err := embRows.Scan(&url, &blob); err != nil {
157- CAST(_word_overlap.common AS REAL),170+ embRows.Close()
158- 0171+ return err
159- )172+ }
160- FROM _word_overlap173+ v := deserializeFloat32(blob)
161- WHERE recs.feed_similarity.feed_a = _word_overlap.feed_a174+ if len(v) > 0 {
162- AND recs.feed_similarity.feed_b = _word_overlap.feed_b175+ embeddings[url] = v
163- `, e.config.DescriptionWeight)176+ }
164-177+ }
165- if _, err := tx.ExecContext(ctx, descUpdate); err != nil {178+ embRows.Close()
166- return err179+
180+ for _, p := range pairs {
181+ vecA, okA := embeddings[p.a]
182+ vecB, okB := embeddings[p.b]
183+ if !okA || !okB {
184+ continue
185+ }
186+ sim := cosineSimilarity(vecA, vecB)
187+ if sim <= 0 {
188+ continue
189+ }
190+ boost := sim * e.config.DescriptionWeight
191+ if _, err := tx.ExecContext(ctx,
192+ `UPDATE _feed_sim_staging SET jaccard = jaccard + ? WHERE feed_a = ? AND feed_b = ?`,
193+ boost, p.a, p.b,
194+ ); err != nil {
195+ return err
196+ }
167 }197 }
168 198
169 return nil199 return nil
170 }200 }
171 201
172-func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {202+func cosineSimilarity(a, b []float32) float64 {
173- tx, err := e.db.BeginTx(ctx, nil)203+ var dot, normA, normB float64
174- if err != nil {204+ for i := range a {
175- return err205+ dot += float64(a[i]) * float64(b[i])
206+ normA += float64(a[i]) * float64(a[i])
207+ normB += float64(b[i]) * float64(b[i])
176 }208 }
177- defer func() { _ = tx.Rollback() }()209+ if normA == 0 || normB == 0 {
178-210+ return 0
179- if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_similarity`); err != nil {
180- return err
181 }211 }
212+ return dot / (math.Sqrt(normA) * math.Sqrt(normB))
213+}
182 214
183- _, err = tx.ExecContext(ctx, `215+// ComputeUserSimilarity recomputes the user_similarity table: subscription
184- INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds)216+// Jaccard + time-decayed like co-occurrence + tag overlap + follow boost.
185- SELECT217+func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
186- s1.user_did,218+ conn, err := e.db.Conn(ctx)
187- s2.user_did,
188- CAST(COUNT(*) AS REAL) / (
189- (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s1.user_did) +
190- (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s2.user_did) -
191- CAST(COUNT(*) AS REAL)
192- ),
193- COUNT(*)
194- FROM articles.subscriptions s1
195- JOIN articles.subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
196- GROUP BY s1.user_did, s2.user_did
197- `)
198 if err != nil {219 if err != nil {
199 return err220 return err
200 }221 }
222+ defer conn.Close()
201 223
202- if _, err := tx.ExecContext(ctx, `224+ {
203- CREATE TEMP TABLE IF NOT EXISTS _likes_count (author_did TEXT PRIMARY KEY, cnt INT)225+ tx, err := conn.BeginTx(ctx, nil)
204- `); err != nil {226+ if err != nil {
205- return err227+ return err
206- }228+ }
207- if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_count`); err != nil {229+ defer func() { _ = tx.Rollback() }()
208- return err
209- }
210- if _, err := tx.ExecContext(ctx, `
211- INSERT INTO _likes_count (author_did, cnt)
212- SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
213- `); err != nil {
214- return err
215- }
216-
217- if _, err := tx.ExecContext(ctx, `
218- CREATE TEMP TABLE IF NOT EXISTS _likes_overlap (user_a TEXT, user_b TEXT, common INT, PRIMARY KEY(user_a, user_b))
219- `); err != nil {
220- return err
221- }
222- if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_overlap`); err != nil {
223- return err
224- }
225- if _, err := tx.ExecContext(ctx, `
226- INSERT INTO _likes_overlap (user_a, user_b, common)
227- SELECT l1.author_did, l2.author_did,
228- CAST(SUM(
229- EXP(-0.023 * CAST(julianday('now') - julianday(l1.created_at) AS REAL))
230- * EXP(-0.023 * CAST(julianday('now') - julianday(l2.created_at) AS REAL))
231- ) AS INTEGER)
232- FROM articles.likes l1
233- JOIN articles.likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
234- AND l1.author_did < l2.author_did
235- WHERE l1.created_at IS NOT NULL AND l2.created_at IS NOT NULL
236- GROUP BY l1.author_did, l2.author_did
237- `); err != nil {
238- return err
239- }
240 230
241- likesUpdate := fmt.Sprintf(`231+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _user_sim_staging (
242- UPDATE recs.user_similarity SET232+ user_a TEXT NOT NULL, user_b TEXT NOT NULL, jaccard REAL NOT NULL,
243- jaccard = jaccard + %g * CAST(_likes_overlap.common AS REAL) / NULLIF(233+ common_feeds INT NOT NULL DEFAULT 0, common_likes INT NOT NULL DEFAULT 0, common_tags INT NOT NULL DEFAULT 0,
244- (SELECT cnt FROM _likes_count WHERE author_did = recs.user_similarity.user_a) +234+ PRIMARY KEY (user_a, user_b))`); err != nil {
245- (SELECT cnt FROM _likes_count WHERE author_did = recs.user_similarity.user_b) -235+ return err
246- CAST(_likes_overlap.common AS REAL),236+ }
247- 0237+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_sim_staging`); err != nil {
248- ),238+ return err
249- common_likes = _likes_overlap.common239+ }
250- FROM _likes_overlap
251- WHERE recs.user_similarity.user_a = _likes_overlap.user_a
252- AND recs.user_similarity.user_b = _likes_overlap.user_b
253- `, e.config.LikesWeight)
254-
255- if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
256- return err
257- }
258 240
259- likesInsert := fmt.Sprintf(`241+ _, err = tx.ExecContext(ctx, `
260- INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)242+ INSERT INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
261- SELECT sub.user_a, sub.user_b, sub.jaccard, 0, sub.common
262- FROM (
263 SELECT243 SELECT
264- lo.user_a,244+ s1.user_did,
265- lo.user_b,245+ s2.user_did,
266- %g * CAST(lo.common AS REAL) / NULLIF(246+ CAST(COUNT(*) AS REAL) / (
267- (SELECT cnt FROM _likes_count WHERE author_did = lo.user_a) +247+ (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s1.user_did) +
268- (SELECT cnt FROM _likes_count WHERE author_did = lo.user_b) -248+ (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s2.user_did) -
269- CAST(lo.common AS REAL),249+ CAST(COUNT(*) AS REAL)
270- 0250+ ),
271- ) AS jaccard,251+ COUNT(*),
272- lo.common252+ 0,
273- FROM _likes_overlap lo
274- ) sub WHERE 1
275- ON CONFLICT(user_a, user_b) DO UPDATE SET
276- jaccard = jaccard + excluded.jaccard,
277- common_likes = excluded.common_likes
278- `, e.config.LikesWeight)
279-
280- if _, err := tx.ExecContext(ctx, likesInsert); err != nil {
281- return err
282- }
283-
284- if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _tag_overlap (user_a TEXT, user_b TEXT, common INT)`); err != nil {
285- return err
286- }
287- if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_overlap`); err != nil {
288- return err
289- }
290-
291- _, err = tx.ExecContext(ctx, `
292- INSERT INTO _tag_overlap (user_a, user_b, common)
293- WITH user_tags AS (
294- SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
295- WHERE tags IS NOT NULL AND tags != ''
296- )
297- SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
298- FROM user_tags t1
299- JOIN user_tags t2 ON t1.tag = t2.tag AND t1.author_did < t2.author_did
300- GROUP BY t1.author_did, t2.author_did
301- `)
302- if err != nil {
303- return err
304- }
305-
306- if _, err := tx.ExecContext(ctx, `
307- CREATE TEMP TABLE IF NOT EXISTS _tag_count (author_did TEXT PRIMARY KEY, cnt INT)
308- `); err != nil {
309- return err
310- }
311- if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_count`); err != nil {
312- return err
313- }
314- if _, err := tx.ExecContext(ctx, `
315- INSERT INTO _tag_count (author_did, cnt)
316- WITH user_tags AS (
317- SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
318- WHERE tags IS NOT NULL AND tags != ''
319- )
320- SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
321- `); err != nil {
322- return err
323- }
324-
325- _, err = tx.ExecContext(ctx, `
326- INSERT OR IGNORE INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
327- SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
328- `)
329- if err != nil {
330- return err
331- }
332-
333- tagsUpdate := fmt.Sprintf(`
334- UPDATE recs.user_similarity SET
335- jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
336- (SELECT cnt FROM _tag_count WHERE author_did = recs.user_similarity.user_a) +
337- (SELECT cnt FROM _tag_count WHERE author_did = recs.user_similarity.user_b) -
338- CAST(_tag_overlap.common AS REAL),
339 0253 0
340- ),254+ FROM articles.subscriptions s1
341- common_tags = _tag_overlap.common255+ JOIN articles.subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
342- FROM _tag_overlap256+ GROUP BY s1.user_did, s2.user_did
343- WHERE recs.user_similarity.user_a = _tag_overlap.user_a257+ `)
344- AND recs.user_similarity.user_b = _tag_overlap.user_b258+ if err != nil {
345- `, e.config.TagsWeight)259+ return err
346-260+ }
347- if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {261+
348- return err262+ if _, err := tx.ExecContext(ctx, `
349- }263+ CREATE TEMP TABLE IF NOT EXISTS _likes_count (author_did TEXT PRIMARY KEY, cnt INT)
350-264+ `); err != nil {
351- followQuery := fmt.Sprintf(`265+ return err
352- INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)266+ }
353- SELECT267+ if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_count`); err != nil {
354- MIN(f.user_did, f.target_did),268+ return err
355- MAX(f.user_did, f.target_did),269+ }
356- %g,270+ if _, err := tx.ExecContext(ctx, `
357- 0, 0, 0271+ INSERT INTO _likes_count (author_did, cnt)
358- FROM main.follows f272+ SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
359- WHERE f.user_did != f.target_did273+ `); err != nil {
360- GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)274+ return err
361- ON CONFLICT(user_a, user_b) DO UPDATE SET275+ }
362- jaccard = jaccard + %g276+
363- `, e.config.FollowBoost, e.config.FollowBoost)277+ if _, err := tx.ExecContext(ctx, `
364-278+ CREATE TEMP TABLE IF NOT EXISTS _likes_overlap (user_a TEXT, user_b TEXT, common INT, PRIMARY KEY(user_a, user_b))
365- if _, err := tx.ExecContext(ctx, followQuery); err != nil {279+ `); err != nil {
366- return err280+ return err
281+ }
282+ if _, err := tx.ExecContext(ctx, `DELETE FROM _likes_overlap`); err != nil {
283+ return err
284+ }
285+ if _, err := tx.ExecContext(ctx, `
286+ INSERT INTO _likes_overlap (user_a, user_b, common)
287+ SELECT l1.author_did, l2.author_did,
288+ CAST(SUM(
289+ EXP(-0.023 * CAST(julianday('now') - julianday(l1.created_at) AS REAL))
290+ * EXP(-0.023 * CAST(julianday('now') - julianday(l2.created_at) AS REAL))
291+ ) AS INTEGER)
292+ FROM articles.likes l1
293+ JOIN articles.likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
294+ AND l1.author_did < l2.author_did
295+ WHERE l1.created_at IS NOT NULL AND l2.created_at IS NOT NULL
296+ GROUP BY l1.author_did, l2.author_did
297+ `); err != nil {
298+ return err
299+ }
300+
301+ likesUpdate := fmt.Sprintf(`
302+ UPDATE _user_sim_staging SET
303+ jaccard = jaccard + %g * CAST(_likes_overlap.common AS REAL) / NULLIF(
304+ (SELECT cnt FROM _likes_count WHERE author_did = _user_sim_staging.user_a) +
305+ (SELECT cnt FROM _likes_count WHERE author_did = _user_sim_staging.user_b) -
306+ CAST(_likes_overlap.common AS REAL),
307+ 0
308+ ),
309+ common_likes = _likes_overlap.common
310+ FROM _likes_overlap
311+ WHERE _user_sim_staging.user_a = _likes_overlap.user_a
312+ AND _user_sim_staging.user_b = _likes_overlap.user_b
313+ `, e.config.LikesWeight)
314+
315+ if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
316+ return err
317+ }
318+
319+ likesInsert := fmt.Sprintf(`
320+ INSERT INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_likes)
321+ SELECT sub.user_a, sub.user_b, sub.jaccard, 0, sub.common
322+ FROM (
323+ SELECT
324+ lo.user_a,
325+ lo.user_b,
326+ %g * CAST(lo.common AS REAL) / NULLIF(
327+ (SELECT cnt FROM _likes_count WHERE author_did = lo.user_a) +
328+ (SELECT cnt FROM _likes_count WHERE author_did = lo.user_b) -
329+ CAST(lo.common AS REAL),
330+ 0
331+ ) AS jaccard,
332+ lo.common
333+ FROM _likes_overlap lo
334+ ) sub WHERE 1
335+ ON CONFLICT(user_a, user_b) DO UPDATE SET
336+ jaccard = jaccard + excluded.jaccard,
337+ common_likes = excluded.common_likes
338+ `, e.config.LikesWeight)
339+
340+ if _, err := tx.ExecContext(ctx, likesInsert); err != nil {
341+ return err
342+ }
343+
344+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _tag_overlap (user_a TEXT, user_b TEXT, common INT)`); err != nil {
345+ return err
346+ }
347+ if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_overlap`); err != nil {
348+ return err
349+ }
350+
351+ _, err = tx.ExecContext(ctx, `
352+ INSERT INTO _tag_overlap (user_a, user_b, common)
353+ WITH user_tags AS (
354+ SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
355+ WHERE tags IS NOT NULL AND tags != ''
356+ )
357+ SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
358+ FROM user_tags t1
359+ JOIN user_tags t2 ON t1.tag = t2.tag AND t1.author_did < t2.author_did
360+ GROUP BY t1.author_did, t2.author_did
361+ `)
362+ if err != nil {
363+ return err
364+ }
365+
366+ if _, err := tx.ExecContext(ctx, `
367+ CREATE TEMP TABLE IF NOT EXISTS _tag_count (author_did TEXT PRIMARY KEY, cnt INT)
368+ `); err != nil {
369+ return err
370+ }
371+ if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_count`); err != nil {
372+ return err
373+ }
374+ if _, err := tx.ExecContext(ctx, `
375+ INSERT INTO _tag_count (author_did, cnt)
376+ WITH user_tags AS (
377+ SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
378+ WHERE tags IS NOT NULL AND tags != ''
379+ )
380+ SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
381+ `); err != nil {
382+ return err
383+ }
384+
385+ _, err = tx.ExecContext(ctx, `
386+ INSERT OR IGNORE INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_tags)
387+ SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
388+ `)
389+ if err != nil {
390+ return err
391+ }
392+
393+ tagsUpdate := fmt.Sprintf(`
394+ UPDATE _user_sim_staging SET
395+ jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
396+ (SELECT cnt FROM _tag_count WHERE author_did = _user_sim_staging.user_a) +
397+ (SELECT cnt FROM _tag_count WHERE author_did = _user_sim_staging.user_b) -
398+ CAST(_tag_overlap.common AS REAL),
399+ 0
400+ ),
401+ common_tags = _tag_overlap.common
402+ FROM _tag_overlap
403+ WHERE _user_sim_staging.user_a = _tag_overlap.user_a
404+ AND _user_sim_staging.user_b = _tag_overlap.user_b
405+ `, e.config.TagsWeight)
406+
407+ if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {
408+ return err
409+ }
410+
411+ followQuery := fmt.Sprintf(`
412+ INSERT INTO _user_sim_staging (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
413+ SELECT
414+ MIN(f.user_did, f.target_did),
415+ MAX(f.user_did, f.target_did),
416+ %g,
417+ 0, 0, 0
418+ FROM main.follows f
419+ WHERE f.user_did != f.target_did
420+ GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)
421+ ON CONFLICT(user_a, user_b) DO UPDATE SET
422+ jaccard = jaccard + %g
423+ `, e.config.FollowBoost, e.config.FollowBoost)
424+
425+ if _, err := tx.ExecContext(ctx, followQuery); err != nil {
426+ return err
427+ }
428+
429+ if err := tx.Commit(); err != nil {
430+ return err
431+ }
432+ }
433+
434+ {
435+ tx, err := conn.BeginTx(ctx, nil)
436+ if err != nil {
437+ return err
438+ }
439+ defer func() { _ = tx.Rollback() }()
440+
441+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_similarity`); err != nil {
442+ return err
443+ }
444+ if _, err := tx.ExecContext(ctx, `INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags) SELECT user_a, user_b, jaccard, common_feeds, common_likes, common_tags FROM _user_sim_staging`); err != nil {
445+ return err
446+ }
447+
448+ e.logger.Info("user similarity computed")
449+ return tx.Commit()
367 }450 }
368-
369- e.logger.Info("user similarity computed")
370- return tx.Commit()
371 }451 }
modified internal/cluster/jaccard_test.go +294 -37
@@ -3,17 +3,21 @@ package cluster
33 import (
44 "context"
55 "fmt"
6+ "hash/fnv"
7+ "log/slog"
68 "os"
9+ "strings"
710 "testing"
811
12+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
913 "pkg.rbrt.fr/glean/internal/db"
1014
1115 "gotest.tools/v3/assert"
12- "log/slog"
1316 )
1417
1518 func setupClusterTestDB(t *testing.T) *db.Databases {
1619 t.Helper()
20+ vec.Auto()
1721 f, err := os.CreateTemp("", "glean-cluster-test-*.db")
1822 assert.NilError(t, err)
1923 assert.NilError(t, f.Close())
@@ -34,13 +38,14 @@ func setupClusterTestDB(t *testing.T) *db.Databases {
3438 dbs, err := db.OpenAll(path)
3539 assert.NilError(t, err)
3640 t.Cleanup(func() { _ = dbs.Close() })
41+ assert.NilError(t, dbs.InitVecTables(8))
3742 return dbs
3843 }
3944
4045 func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
4146 t.Helper()
4247
43- users := []string{"did:test:alice", "did:test:bob", "did:test:carol"}
48+ users := []string{"did:test:alice", "did:test:bob", "did:test:carol", "did:test:dave"}
4449 for _, did := range users {
4550 _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, did)
4651 assert.NilError(t, err)
@@ -80,6 +85,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Databases) {
8085 follows := []struct{ user, target string }{
8186 {"did:test:alice", "did:test:bob"},
8287 {"did:test:bob", "did:test:carol"},
88+ {"did:test:carol", "did:test:dave"},
8389 }
8490 for _, f := range follows {
8591 _, err := dbs.DB().ExecContext(ctx, `INSERT OR IGNORE INTO follows (user_did, target_did) VALUES (?, ?)`, f.user, f.target)
@@ -88,7 +94,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Databases) {
8894 }
8995
9096 func newTestEngine(dbs *db.Databases) *Engine {
91- return NewEngine(dbs.DB(), slog.Default())
97+ return NewEngine(dbs.DB(), NewMockEmbedder(8), slog.Default())
9298 }
9399
94100 func TestComputeFeedSimilarity(t *testing.T) {
@@ -219,14 +225,14 @@ func TestRecordImpressions(t *testing.T) {
219225
220226 var count int
221227 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
222- `SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
228+ `SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
223229 assert.Equal(t, count, 2)
224230
225231 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
226232
227233 var shownCount int
228234 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
229- `SELECT shown_count FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))
235+ `SELECT shown_count FROM main.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))
230236 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression")
231237 }
232238
@@ -244,7 +250,7 @@ func TestMarkImpressionActed(t *testing.T) {
244250
245251 var acted bool
246252 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
247- `SELECT acted FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))
253+ `SELECT acted FROM main.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))
248254 assert.Assert(t, acted, "impression should be marked as acted")
249255 }
250256
@@ -257,18 +263,45 @@ func TestComputeFollowDistances(t *testing.T) {
257263 engine := newTestEngine(dbs)
258264 assert.NilError(t, engine.ComputeFollowDistances(ctx))
259265
260- var d1, d2 int
266+ var d1, d2, d3 int
261267 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
262268 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 1`).Scan(&d1))
263269 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
264270 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2))
265- assert.Assert(t, d1 >= 2, "expected at least 2 direct follow distances")
271+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
272+ `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 3`).Scan(&d3))
273+ assert.Assert(t, d1 >= 3, "expected at least 3 direct follow distances")
266274 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)")
275+ assert.Assert(t, d3 >= 1, "expected at least 1 three-hop distance (alice -> bob -> carol -> dave)")
276+
277+ var exists int
278+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
279+ `SELECT COUNT(*) FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&exists))
280+ assert.Assert(t, exists == 1, "alice should reach carol")
267281
268- var dist int
269282 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
270- `SELECT distance FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&dist))
271- assert.Equal(t, dist, 2, "alice should be 2 hops from carol")
283+ `SELECT COUNT(*) FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:dave'`).Scan(&exists))
284+ assert.Assert(t, exists == 1, "alice should reach dave via 3 hops")
285+}
286+
287+func TestComputeFollowDistancesData_SplitReadWrite(t *testing.T) {
288+ ctx := context.Background()
289+ dbs := setupClusterTestDB(t)
290+ seedClusterData(t, ctx, dbs)
291+ seedFollowData(t, ctx, dbs)
292+
293+ engine := newTestEngine(dbs)
294+
295+ sources := []string{"did:test:alice", "did:test:bob", "did:test:carol", "did:test:dave"}
296+ distances, err := engine.ComputeFollowDistancesData(ctx, sources)
297+ assert.NilError(t, err)
298+ assert.Assert(t, len(distances) > 0, "expected follow distance pairs")
299+
300+ assert.NilError(t, engine.WriteFollowDistances(ctx, distances))
301+
302+ var count int
303+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.follow_distances`).Scan(&count))
304+ assert.Equal(t, count, len(distances))
272305 }
273306
274307 func TestAutoDismissStale(t *testing.T) {
@@ -279,12 +312,12 @@ func TestAutoDismissStale(t *testing.T) {
279312 engine := newTestEngine(dbs)
280313
281314 _, err := dbs.DB().ExecContext(ctx, `
282- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
283- VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 0)
315+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
316+ VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-6 days'), datetime('now'), 6, 0)
284317 `)
285318 assert.NilError(t, err)
286319
287- assert.NilError(t, engine.AutoDismissStale(ctx, 15, 30))
320+ assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
288321
289322 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")
290323 assert.NilError(t, err)
@@ -299,12 +332,12 @@ func TestAutoDismissStale_DoesNotDismissRecent(t *testing.T) {
299332 engine := newTestEngine(dbs)
300333
301334 _, err := dbs.DB().ExecContext(ctx, `
302- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
335+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
303336 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0)
304337 `)
305338 assert.NilError(t, err)
306339
307- assert.NilError(t, engine.AutoDismissStale(ctx, 15, 30))
340+ assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
308341
309342 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")
310343 assert.NilError(t, err)
@@ -319,12 +352,12 @@ func TestAutoDismissStale_DoesNotDismissActed(t *testing.T) {
319352 engine := newTestEngine(dbs)
320353
321354 _, err := dbs.DB().ExecContext(ctx, `
322- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
323- VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 1)
355+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
356+ VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-6 days'), datetime('now'), 6, 1)
324357 `)
325358 assert.NilError(t, err)
326359
327- assert.NilError(t, engine.AutoDismissStale(ctx, 15, 30))
360+ assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
328361
329362 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")
330363 assert.NilError(t, err)
@@ -384,6 +417,7 @@ func TestSignalWeights_Default(t *testing.T) {
384417 assert.Equal(t, w.WSocial, 0.7)
385418 assert.Equal(t, w.WPop, 0.2)
386419 assert.Equal(t, w.WCategory, 0.4)
420+ assert.Equal(t, w.WContent, 0.4)
387421 }
388422
389423 func TestSignalWeights_RewardPenalize(t *testing.T) {
@@ -394,13 +428,13 @@ func TestSignalWeights_RewardPenalize(t *testing.T) {
394428 engine := newTestEngine(dbs)
395429
396430 _, err := dbs.DB().ExecContext(ctx, `
397- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
431+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
398432 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1)
399433 `)
400434 assert.NilError(t, err)
401435 for i := range minActionsTune {
402436 _, err = dbs.DB().ExecContext(ctx, `
403- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
437+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
404438 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1)
405439 `, fmt.Sprintf("https://%d.com/feed", i))
406440 assert.NilError(t, err)
@@ -472,7 +506,7 @@ func TestDismissArticle(t *testing.T) {
472506
473507 var count int
474508 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
475- `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))
509+ `SELECT COUNT(*) FROM main.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))
476510 assert.Equal(t, count, 1)
477511 }
478512
@@ -501,11 +535,11 @@ func TestDismissFeed_Idempotent(t *testing.T) {
501535
502536 var count int
503537 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
504- `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))
538+ `SELECT COUNT(*) FROM main.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))
505539 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows")
506540 }
507541
508-func TestDescriptionBasedFeedSimilarity(t *testing.T) {
542+func TestEmbeddingBasedFeedSimilarity(t *testing.T) {
509543 ctx := context.Background()
510544 dbs := setupClusterTestDB(t)
511545
@@ -514,30 +548,253 @@ func TestDescriptionBasedFeedSimilarity(t *testing.T) {
514548 _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
515549 assert.NilError(t, err)
516550
517- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
551+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
518552 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")
519553 assert.NilError(t, err)
520- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
554+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
521555 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")
522556 assert.NilError(t, err)
523557
524- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://go.com/feed")
558+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:alice", "https://go.com/feed")
559+ assert.NilError(t, err)
560+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:alice", "https://rust.com/feed")
561+ assert.NilError(t, err)
562+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:bob", "https://go.com/feed")
563+ assert.NilError(t, err)
564+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:bob", "https://rust.com/feed")
565+ assert.NilError(t, err)
566+
567+ engine := newTestEngine(dbs)
568+
569+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
570+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
571+
572+ var jaccard float64
573+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
574+ `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,
575+ "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
576+ assert.Assert(t, jaccard > 1.0, "embedding cosine similarity should boost feed similarity above pure Jaccard")
577+}
578+
579+type MockEmbedder struct {
580+ dimension int
581+}
582+
583+func NewMockEmbedder(dimension int) *MockEmbedder {
584+ return &MockEmbedder{dimension: dimension}
585+}
586+
587+func (m *MockEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
588+ result := make([][]float32, len(texts))
589+ for i, text := range texts {
590+ vec := make([]float32, m.dimension)
591+ for word := range strings.FieldsSeq(strings.ToLower(text)) {
592+ if len(word) < 2 {
593+ continue
594+ }
595+ h := fnv.New32a()
596+ h.Write([]byte(word))
597+ idx := h.Sum32() % uint32(m.dimension)
598+ vec[idx] += 1.0
599+ }
600+ result[i] = vec
601+ }
602+ return result, nil
603+}
604+
605+func (m *MockEmbedder) Dimension() int {
606+ return m.dimension
607+}
608+
609+func TestComputeArticleEmbeddings(t *testing.T) {
610+ ctx := context.Background()
611+ dbs := setupClusterTestDB(t)
612+
613+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 1)`,
614+ "https://tech.com/feed", "Tech Feed", "https://tech.com")
615+ assert.NilError(t, err)
616+
617+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url) VALUES (?, ?, ?, ?, ?)`,
618+ "https://tech.com/feed", "1", "golang programming language tutorial", "learn the go programming language for backend development", "https://tech.com/go")
619+ assert.NilError(t, err)
620+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url) VALUES (?, ?, ?, ?, ?)`,
621+ "https://tech.com/feed", "2", "rust programming language guide", "learn the rust programming language for systems development", "https://tech.com/rust")
622+ assert.NilError(t, err)
623+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url) VALUES (?, ?, ?, ?, ?)`,
624+ "https://tech.com/feed", "3", "cooking recipes for dinner", "easy dinner recipes for the whole family", "https://tech.com/cook")
625+ assert.NilError(t, err)
626+
627+ engine := newTestEngine(dbs)
628+
629+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
630+
631+ var count int
632+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.article_embeddings`).Scan(&count))
633+ assert.Equal(t, count, 3, "expected 3 article embeddings")
634+}
635+
636+func TestArticleRecommendationsWithContentBoost(t *testing.T) {
637+ ctx := context.Background()
638+ dbs := setupClusterTestDB(t)
639+
640+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice")
641+ assert.NilError(t, err)
642+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
643+ assert.NilError(t, err)
644+
645+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
646+ "https://tech.com/feed", "Tech Feed", "https://tech.com")
647+ assert.NilError(t, err)
648+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
649+ "https://dev.com/feed", "Dev Feed", "https://dev.com")
650+ assert.NilError(t, err)
651+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
652+ "https://shared.com/feed", "Shared Feed", "https://shared.com")
653+ assert.NilError(t, err)
654+
655+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://tech.com/feed")
656+ assert.NilError(t, err)
657+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://shared.com/feed")
658+ assert.NilError(t, err)
659+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://dev.com/feed")
660+ assert.NilError(t, err)
661+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://shared.com/feed")
662+ assert.NilError(t, err)
663+
664+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
665+ "https://tech.com/feed", "1", "golang programming tutorial", "learn go programming", "https://tech.com/go")
525666 assert.NilError(t, err)
526- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://rust.com/feed")
667+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
668+ "https://dev.com/feed", "2", "rust programming tutorial", "learn rust programming", "https://dev.com/rust")
669+ assert.NilError(t, err)
670+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
671+ "https://dev.com/feed", "3", "cooking dinner recipes", "easy dinner recipes", "https://dev.com/cook")
672+ assert.NilError(t, err)
673+
674+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
675+ "at://alice/like/1", "did:test:alice", "https://tech.com/feed", "https://tech.com/go")
676+ assert.NilError(t, err)
677+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
678+ "at://bob/like/1", "did:test:bob", "https://dev.com/feed", "https://dev.com/rust")
527679 assert.NilError(t, err)
528680
529681 engine := newTestEngine(dbs)
682+
530683 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
684+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
685+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
686+
687+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", 10)
688+ assert.NilError(t, err)
689+ assert.Assert(t, len(recs) > 0, "alice should get article recommendations")
690+}
691+
692+func TestFeedEmbeddingRecomputedOnDescriptionChange(t *testing.T) {
693+ ctx := context.Background()
694+ dbs := setupClusterTestDB(t)
695+
696+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
697+ "https://go.com/feed", "Go Blog", "https://go.com", "old description")
698+ assert.NilError(t, err)
699+
700+ engine := newTestEngine(dbs)
701+
702+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
531703
532704 var count int
533- assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_similarity`).Scan(&count))
534- assert.Assert(t, count >= 0, "description-based similarity should produce pairs")
535-
536- if count > 0 {
537- var jaccard float64
538- assert.NilError(t, dbs.DB().QueryRowContext(ctx,
539- `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,
540- "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
541- assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")
705+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_embeddings`).Scan(&count))
706+ assert.Equal(t, count, 1)
707+
708+ _, err = dbs.DB().ExecContext(ctx, `UPDATE articles.feeds SET description = 'new description' WHERE feed_url = 'https://go.com/feed'`)
709+ assert.NilError(t, err)
710+
711+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
712+
713+ var sourceText string
714+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT source_text FROM recs.feed_embedding_meta WHERE feed_url = 'https://go.com/feed'`).Scan(&sourceText))
715+ assert.Assert(t, sourceText == "Go Blog new description", "embedding should be recomputed when description changes, got: %s", sourceText)
716+}
717+
718+func TestTimeDecayedFeedSimilarity(t *testing.T) {
719+ ctx := context.Background()
720+ dbs := setupClusterTestDB(t)
721+
722+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice")
723+ assert.NilError(t, err)
724+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
725+ assert.NilError(t, err)
726+
727+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
728+ "https://a.com/feed", "Feed A", "https://a.com")
729+ assert.NilError(t, err)
730+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
731+ "https://b.com/feed", "Feed B", "https://b.com")
732+ assert.NilError(t, err)
733+
734+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now', '-60 days'))`,
735+ "did:test:alice", "https://a.com/feed")
736+ assert.NilError(t, err)
737+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now'))`,
738+ "did:test:bob", "https://a.com/feed")
739+ assert.NilError(t, err)
740+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now'))`,
741+ "did:test:alice", "https://b.com/feed")
742+ assert.NilError(t, err)
743+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now'))`,
744+ "did:test:bob", "https://b.com/feed")
745+ assert.NilError(t, err)
746+
747+ engine := newTestEngine(dbs)
748+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
749+
750+ var jaccard float64
751+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
752+ `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = 'https://a.com/feed' AND feed_b = 'https://b.com/feed'`).Scan(&jaccard))
753+ assert.Assert(t, jaccard > 0, "time-decayed feed similarity should be positive")
754+ assert.Assert(t, jaccard < 1.0, "time decay should reduce similarity below raw Jaccard")
755+}
756+
757+func TestColdStartFromEmbeddings(t *testing.T) {
758+ ctx := context.Background()
759+ dbs := setupClusterTestDB(t)
760+
761+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:newuser")
762+ assert.NilError(t, err)
763+
764+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
765+ "https://go.com/feed", "Go Blog", "https://go.com", "golang programming language")
766+ assert.NilError(t, err)
767+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
768+ "https://godev.com/feed", "Go Dev", "https://godev.com", "golang development tutorials")
769+ assert.NilError(t, err)
770+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
771+ "https://cooking.com/feed", "Cooking", "https://cooking.com", "recipes for dinner")
772+ assert.NilError(t, err)
773+
774+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`,
775+ "did:test:newuser", "https://go.com/feed")
776+ assert.NilError(t, err)
777+
778+ engine := newTestEngine(dbs)
779+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
780+
781+ recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10)
782+ assert.NilError(t, err)
783+ assert.Assert(t, len(recs) > 0, "embedding cold start should return recommendations")
784+
785+ for _, r := range recs {
786+ assert.Assert(t, r.FeedURL != "https://go.com/feed", "should not recommend already subscribed feed")
787+ }
788+}
789+
790+func TestNormalizeFeedScores(t *testing.T) {
791+ recs := []*FeedRecommendation{
792+ {FeedURL: "a", Score: 10.0},
793+ {FeedURL: "b", Score: 5.0},
794+ {FeedURL: "c", Score: 1.0},
542795 }
796+ normalizeFeedScores(recs)
797+ assert.Equal(t, recs[0].Score, 1.0)
798+ assert.Equal(t, recs[1].Score, 4.0/9.0)
799+ assert.Equal(t, recs[2].Score, 0.0)
543800 }
@@ -3,17 +3,21 @@ package cluster
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "hash/fnv"
7+ "log/slog"
6 "os"8 "os"
9+ "strings"
7 "testing"10 "testing"
8 11
12+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
9 "pkg.rbrt.fr/glean/internal/db"13 "pkg.rbrt.fr/glean/internal/db"
10 14
11 "gotest.tools/v3/assert"15 "gotest.tools/v3/assert"
12- "log/slog"
13 )16 )
14 17
15 func setupClusterTestDB(t *testing.T) *db.Databases {18 func setupClusterTestDB(t *testing.T) *db.Databases {
16 t.Helper()19 t.Helper()
20+ vec.Auto()
17 f, err := os.CreateTemp("", "glean-cluster-test-*.db")21 f, err := os.CreateTemp("", "glean-cluster-test-*.db")
18 assert.NilError(t, err)22 assert.NilError(t, err)
19 assert.NilError(t, f.Close())23 assert.NilError(t, f.Close())
@@ -34,13 +38,14 @@ func setupClusterTestDB(t *testing.T) *db.Databases {
34 dbs, err := db.OpenAll(path)38 dbs, err := db.OpenAll(path)
35 assert.NilError(t, err)39 assert.NilError(t, err)
36 t.Cleanup(func() { _ = dbs.Close() })40 t.Cleanup(func() { _ = dbs.Close() })
41+ assert.NilError(t, dbs.InitVecTables(8))
37 return dbs42 return dbs
38 }43 }
39 44
40 func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {45 func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
41 t.Helper()46 t.Helper()
42 47
43- users := []string{"did:test:alice", "did:test:bob", "did:test:carol"}48+ users := []string{"did:test:alice", "did:test:bob", "did:test:carol", "did:test:dave"}
44 for _, did := range users {49 for _, did := range users {
45 _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, did)50 _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, did)
46 assert.NilError(t, err)51 assert.NilError(t, err)
@@ -80,6 +85,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Databases) {
80 follows := []struct{ user, target string }{85 follows := []struct{ user, target string }{
81 {"did:test:alice", "did:test:bob"},86 {"did:test:alice", "did:test:bob"},
82 {"did:test:bob", "did:test:carol"},87 {"did:test:bob", "did:test:carol"},
88+ {"did:test:carol", "did:test:dave"},
83 }89 }
84 for _, f := range follows {90 for _, f := range follows {
85 _, err := dbs.DB().ExecContext(ctx, `INSERT OR IGNORE INTO follows (user_did, target_did) VALUES (?, ?)`, f.user, f.target)91 _, err := dbs.DB().ExecContext(ctx, `INSERT OR IGNORE INTO follows (user_did, target_did) VALUES (?, ?)`, f.user, f.target)
@@ -88,7 +94,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Databases) {
88 }94 }
89 95
90 func newTestEngine(dbs *db.Databases) *Engine {96 func newTestEngine(dbs *db.Databases) *Engine {
91- return NewEngine(dbs.DB(), slog.Default())97+ return NewEngine(dbs.DB(), NewMockEmbedder(8), slog.Default())
92 }98 }
93 99
94 func TestComputeFeedSimilarity(t *testing.T) {100 func TestComputeFeedSimilarity(t *testing.T) {
@@ -219,14 +225,14 @@ func TestRecordImpressions(t *testing.T) {
219 225
220 var count int226 var count int
221 assert.NilError(t, dbs.DB().QueryRowContext(ctx,227 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
222- `SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))228+ `SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
223 assert.Equal(t, count, 2)229 assert.Equal(t, count, 2)
224 230
225 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))231 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
226 232
227 var shownCount int233 var shownCount int
228 assert.NilError(t, dbs.DB().QueryRowContext(ctx,234 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
229- `SELECT shown_count FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))235+ `SELECT shown_count FROM main.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))
230 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression")236 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression")
231 }237 }
232 238
@@ -244,7 +250,7 @@ func TestMarkImpressionActed(t *testing.T) {
244 250
245 var acted bool251 var acted bool
246 assert.NilError(t, dbs.DB().QueryRowContext(ctx,252 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
247- `SELECT acted FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))253+ `SELECT acted FROM main.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))
248 assert.Assert(t, acted, "impression should be marked as acted")254 assert.Assert(t, acted, "impression should be marked as acted")
249 }255 }
250 256
@@ -257,18 +263,45 @@ func TestComputeFollowDistances(t *testing.T) {
257 engine := newTestEngine(dbs)263 engine := newTestEngine(dbs)
258 assert.NilError(t, engine.ComputeFollowDistances(ctx))264 assert.NilError(t, engine.ComputeFollowDistances(ctx))
259 265
260- var d1, d2 int266+ var d1, d2, d3 int
261 assert.NilError(t, dbs.DB().QueryRowContext(ctx,267 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
262 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 1`).Scan(&d1))268 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 1`).Scan(&d1))
263 assert.NilError(t, dbs.DB().QueryRowContext(ctx,269 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
264 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2))270 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2))
265- assert.Assert(t, d1 >= 2, "expected at least 2 direct follow distances")271+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
272+ `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 3`).Scan(&d3))
273+ assert.Assert(t, d1 >= 3, "expected at least 3 direct follow distances")
266 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)")274 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)")
275+ assert.Assert(t, d3 >= 1, "expected at least 1 three-hop distance (alice -> bob -> carol -> dave)")
276+
277+ var exists int
278+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
279+ `SELECT COUNT(*) FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&exists))
280+ assert.Assert(t, exists == 1, "alice should reach carol")
267 281
268- var dist int
269 assert.NilError(t, dbs.DB().QueryRowContext(ctx,282 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
270- `SELECT distance FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&dist))283+ `SELECT COUNT(*) FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:dave'`).Scan(&exists))
271- assert.Equal(t, dist, 2, "alice should be 2 hops from carol")284+ assert.Assert(t, exists == 1, "alice should reach dave via 3 hops")
285+}
286+
287+func TestComputeFollowDistancesData_SplitReadWrite(t *testing.T) {
288+ ctx := context.Background()
289+ dbs := setupClusterTestDB(t)
290+ seedClusterData(t, ctx, dbs)
291+ seedFollowData(t, ctx, dbs)
292+
293+ engine := newTestEngine(dbs)
294+
295+ sources := []string{"did:test:alice", "did:test:bob", "did:test:carol", "did:test:dave"}
296+ distances, err := engine.ComputeFollowDistancesData(ctx, sources)
297+ assert.NilError(t, err)
298+ assert.Assert(t, len(distances) > 0, "expected follow distance pairs")
299+
300+ assert.NilError(t, engine.WriteFollowDistances(ctx, distances))
301+
302+ var count int
303+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.follow_distances`).Scan(&count))
304+ assert.Equal(t, count, len(distances))
272 }305 }
273 306
274 func TestAutoDismissStale(t *testing.T) {307 func TestAutoDismissStale(t *testing.T) {
@@ -279,12 +312,12 @@ func TestAutoDismissStale(t *testing.T) {
279 engine := newTestEngine(dbs)312 engine := newTestEngine(dbs)
280 313
281 _, err := dbs.DB().ExecContext(ctx, `314 _, err := dbs.DB().ExecContext(ctx, `
282- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)315+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
283- VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 0)316+ VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-6 days'), datetime('now'), 6, 0)
284 `)317 `)
285 assert.NilError(t, err)318 assert.NilError(t, err)
286 319
287- assert.NilError(t, engine.AutoDismissStale(ctx, 15, 30))320+ assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
288 321
289 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")322 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://stale.com/feed")
290 assert.NilError(t, err)323 assert.NilError(t, err)
@@ -299,12 +332,12 @@ func TestAutoDismissStale_DoesNotDismissRecent(t *testing.T) {
299 engine := newTestEngine(dbs)332 engine := newTestEngine(dbs)
300 333
301 _, err := dbs.DB().ExecContext(ctx, `334 _, err := dbs.DB().ExecContext(ctx, `
302- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)335+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
303 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0)336 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0)
304 `)337 `)
305 assert.NilError(t, err)338 assert.NilError(t, err)
306 339
307- assert.NilError(t, engine.AutoDismissStale(ctx, 15, 30))340+ assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
308 341
309 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")342 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://recent.com/feed")
310 assert.NilError(t, err)343 assert.NilError(t, err)
@@ -319,12 +352,12 @@ func TestAutoDismissStale_DoesNotDismissActed(t *testing.T) {
319 engine := newTestEngine(dbs)352 engine := newTestEngine(dbs)
320 353
321 _, err := dbs.DB().ExecContext(ctx, `354 _, err := dbs.DB().ExecContext(ctx, `
322- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)355+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
323- VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 1)356+ VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-6 days'), datetime('now'), 6, 1)
324 `)357 `)
325 assert.NilError(t, err)358 assert.NilError(t, err)
326 359
327- assert.NilError(t, engine.AutoDismissStale(ctx, 15, 30))360+ assert.NilError(t, engine.AutoDismissStale(ctx, 5, 5))
328 361
329 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")362 dismissed, err := engine.IsFeedDismissed(ctx, "did:test:alice", "https://acted.com/feed")
330 assert.NilError(t, err)363 assert.NilError(t, err)
@@ -384,6 +417,7 @@ func TestSignalWeights_Default(t *testing.T) {
384 assert.Equal(t, w.WSocial, 0.7)417 assert.Equal(t, w.WSocial, 0.7)
385 assert.Equal(t, w.WPop, 0.2)418 assert.Equal(t, w.WPop, 0.2)
386 assert.Equal(t, w.WCategory, 0.4)419 assert.Equal(t, w.WCategory, 0.4)
420+ assert.Equal(t, w.WContent, 0.4)
387 }421 }
388 422
389 func TestSignalWeights_RewardPenalize(t *testing.T) {423 func TestSignalWeights_RewardPenalize(t *testing.T) {
@@ -394,13 +428,13 @@ func TestSignalWeights_RewardPenalize(t *testing.T) {
394 engine := newTestEngine(dbs)428 engine := newTestEngine(dbs)
395 429
396 _, err := dbs.DB().ExecContext(ctx, `430 _, err := dbs.DB().ExecContext(ctx, `
397- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)431+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
398 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1)432 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1)
399 `)433 `)
400 assert.NilError(t, err)434 assert.NilError(t, err)
401 for i := range minActionsTune {435 for i := range minActionsTune {
402 _, err = dbs.DB().ExecContext(ctx, `436 _, err = dbs.DB().ExecContext(ctx, `
403- INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)437+ INSERT INTO main.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
404 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1)438 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1)
405 `, fmt.Sprintf("https://%d.com/feed", i))439 `, fmt.Sprintf("https://%d.com/feed", i))
406 assert.NilError(t, err)440 assert.NilError(t, err)
@@ -472,7 +506,7 @@ func TestDismissArticle(t *testing.T) {
472 506
473 var count int507 var count int
474 assert.NilError(t, dbs.DB().QueryRowContext(ctx,508 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
475- `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))509+ `SELECT COUNT(*) FROM main.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))
476 assert.Equal(t, count, 1)510 assert.Equal(t, count, 1)
477 }511 }
478 512
@@ -501,11 +535,11 @@ func TestDismissFeed_Idempotent(t *testing.T) {
501 535
502 var count int536 var count int
503 assert.NilError(t, dbs.DB().QueryRowContext(ctx,537 assert.NilError(t, dbs.DB().QueryRowContext(ctx,
504- `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))538+ `SELECT COUNT(*) FROM main.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))
505 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows")539 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows")
506 }540 }
507 541
508-func TestDescriptionBasedFeedSimilarity(t *testing.T) {542+func TestEmbeddingBasedFeedSimilarity(t *testing.T) {
509 ctx := context.Background()543 ctx := context.Background()
510 dbs := setupClusterTestDB(t)544 dbs := setupClusterTestDB(t)
511 545
@@ -514,30 +548,253 @@ func TestDescriptionBasedFeedSimilarity(t *testing.T) {
514 _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")548 _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
515 assert.NilError(t, err)549 assert.NilError(t, err)
516 550
517- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,551+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
518 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")552 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")
519 assert.NilError(t, err)553 assert.NilError(t, err)
520- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,554+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
521 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")555 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")
522 assert.NilError(t, err)556 assert.NilError(t, err)
523 557
524- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://go.com/feed")558+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:alice", "https://go.com/feed")
559+ assert.NilError(t, err)
560+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:alice", "https://rust.com/feed")
561+ assert.NilError(t, err)
562+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:bob", "https://go.com/feed")
563+ assert.NilError(t, err)
564+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, "did:test:bob", "https://rust.com/feed")
565+ assert.NilError(t, err)
566+
567+ engine := newTestEngine(dbs)
568+
569+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
570+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
571+
572+ var jaccard float64
573+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
574+ `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,
575+ "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
576+ assert.Assert(t, jaccard > 1.0, "embedding cosine similarity should boost feed similarity above pure Jaccard")
577+}
578+
579+type MockEmbedder struct {
580+ dimension int
581+}
582+
583+func NewMockEmbedder(dimension int) *MockEmbedder {
584+ return &MockEmbedder{dimension: dimension}
585+}
586+
587+func (m *MockEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
588+ result := make([][]float32, len(texts))
589+ for i, text := range texts {
590+ vec := make([]float32, m.dimension)
591+ for word := range strings.FieldsSeq(strings.ToLower(text)) {
592+ if len(word) < 2 {
593+ continue
594+ }
595+ h := fnv.New32a()
596+ h.Write([]byte(word))
597+ idx := h.Sum32() % uint32(m.dimension)
598+ vec[idx] += 1.0
599+ }
600+ result[i] = vec
601+ }
602+ return result, nil
603+}
604+
605+func (m *MockEmbedder) Dimension() int {
606+ return m.dimension
607+}
608+
609+func TestComputeArticleEmbeddings(t *testing.T) {
610+ ctx := context.Background()
611+ dbs := setupClusterTestDB(t)
612+
613+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 1)`,
614+ "https://tech.com/feed", "Tech Feed", "https://tech.com")
615+ assert.NilError(t, err)
616+
617+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url) VALUES (?, ?, ?, ?, ?)`,
618+ "https://tech.com/feed", "1", "golang programming language tutorial", "learn the go programming language for backend development", "https://tech.com/go")
619+ assert.NilError(t, err)
620+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url) VALUES (?, ?, ?, ?, ?)`,
621+ "https://tech.com/feed", "2", "rust programming language guide", "learn the rust programming language for systems development", "https://tech.com/rust")
622+ assert.NilError(t, err)
623+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url) VALUES (?, ?, ?, ?, ?)`,
624+ "https://tech.com/feed", "3", "cooking recipes for dinner", "easy dinner recipes for the whole family", "https://tech.com/cook")
625+ assert.NilError(t, err)
626+
627+ engine := newTestEngine(dbs)
628+
629+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
630+
631+ var count int
632+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.article_embeddings`).Scan(&count))
633+ assert.Equal(t, count, 3, "expected 3 article embeddings")
634+}
635+
636+func TestArticleRecommendationsWithContentBoost(t *testing.T) {
637+ ctx := context.Background()
638+ dbs := setupClusterTestDB(t)
639+
640+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice")
641+ assert.NilError(t, err)
642+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
643+ assert.NilError(t, err)
644+
645+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
646+ "https://tech.com/feed", "Tech Feed", "https://tech.com")
647+ assert.NilError(t, err)
648+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
649+ "https://dev.com/feed", "Dev Feed", "https://dev.com")
650+ assert.NilError(t, err)
651+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
652+ "https://shared.com/feed", "Shared Feed", "https://shared.com")
653+ assert.NilError(t, err)
654+
655+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://tech.com/feed")
656+ assert.NilError(t, err)
657+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://shared.com/feed")
658+ assert.NilError(t, err)
659+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://dev.com/feed")
660+ assert.NilError(t, err)
661+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://shared.com/feed")
662+ assert.NilError(t, err)
663+
664+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
665+ "https://tech.com/feed", "1", "golang programming tutorial", "learn go programming", "https://tech.com/go")
525 assert.NilError(t, err)666 assert.NilError(t, err)
526- _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://rust.com/feed")667+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
668+ "https://dev.com/feed", "2", "rust programming tutorial", "learn rust programming", "https://dev.com/rust")
669+ assert.NilError(t, err)
670+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
671+ "https://dev.com/feed", "3", "cooking dinner recipes", "easy dinner recipes", "https://dev.com/cook")
672+ assert.NilError(t, err)
673+
674+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
675+ "at://alice/like/1", "did:test:alice", "https://tech.com/feed", "https://tech.com/go")
676+ assert.NilError(t, err)
677+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
678+ "at://bob/like/1", "did:test:bob", "https://dev.com/feed", "https://dev.com/rust")
527 assert.NilError(t, err)679 assert.NilError(t, err)
528 680
529 engine := newTestEngine(dbs)681 engine := newTestEngine(dbs)
682+
530 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))683 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
684+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
685+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
686+
687+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", 10)
688+ assert.NilError(t, err)
689+ assert.Assert(t, len(recs) > 0, "alice should get article recommendations")
690+}
691+
692+func TestFeedEmbeddingRecomputedOnDescriptionChange(t *testing.T) {
693+ ctx := context.Background()
694+ dbs := setupClusterTestDB(t)
695+
696+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
697+ "https://go.com/feed", "Go Blog", "https://go.com", "old description")
698+ assert.NilError(t, err)
699+
700+ engine := newTestEngine(dbs)
701+
702+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
531 703
532 var count int704 var count int
533- assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_similarity`).Scan(&count))705+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_embeddings`).Scan(&count))
534- assert.Assert(t, count >= 0, "description-based similarity should produce pairs")706+ assert.Equal(t, count, 1)
535-707+
536- if count > 0 {708+ _, err = dbs.DB().ExecContext(ctx, `UPDATE articles.feeds SET description = 'new description' WHERE feed_url = 'https://go.com/feed'`)
537- var jaccard float64709+ assert.NilError(t, err)
538- assert.NilError(t, dbs.DB().QueryRowContext(ctx,710+
539- `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,711+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
540- "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))712+
541- assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")713+ var sourceText string
714+ assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT source_text FROM recs.feed_embedding_meta WHERE feed_url = 'https://go.com/feed'`).Scan(&sourceText))
715+ assert.Assert(t, sourceText == "Go Blog new description", "embedding should be recomputed when description changes, got: %s", sourceText)
716+}
717+
718+func TestTimeDecayedFeedSimilarity(t *testing.T) {
719+ ctx := context.Background()
720+ dbs := setupClusterTestDB(t)
721+
722+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice")
723+ assert.NilError(t, err)
724+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
725+ assert.NilError(t, err)
726+
727+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
728+ "https://a.com/feed", "Feed A", "https://a.com")
729+ assert.NilError(t, err)
730+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
731+ "https://b.com/feed", "Feed B", "https://b.com")
732+ assert.NilError(t, err)
733+
734+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now', '-60 days'))`,
735+ "did:test:alice", "https://a.com/feed")
736+ assert.NilError(t, err)
737+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now'))`,
738+ "did:test:bob", "https://a.com/feed")
739+ assert.NilError(t, err)
740+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now'))`,
741+ "did:test:alice", "https://b.com/feed")
742+ assert.NilError(t, err)
743+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url, added_at) VALUES (?, ?, datetime('now'))`,
744+ "did:test:bob", "https://b.com/feed")
745+ assert.NilError(t, err)
746+
747+ engine := newTestEngine(dbs)
748+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
749+
750+ var jaccard float64
751+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
752+ `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = 'https://a.com/feed' AND feed_b = 'https://b.com/feed'`).Scan(&jaccard))
753+ assert.Assert(t, jaccard > 0, "time-decayed feed similarity should be positive")
754+ assert.Assert(t, jaccard < 1.0, "time decay should reduce similarity below raw Jaccard")
755+}
756+
757+func TestColdStartFromEmbeddings(t *testing.T) {
758+ ctx := context.Background()
759+ dbs := setupClusterTestDB(t)
760+
761+ _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:newuser")
762+ assert.NilError(t, err)
763+
764+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
765+ "https://go.com/feed", "Go Blog", "https://go.com", "golang programming language")
766+ assert.NilError(t, err)
767+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
768+ "https://godev.com/feed", "Go Dev", "https://godev.com", "golang development tutorials")
769+ assert.NilError(t, err)
770+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, ?, 'rss', 2)`,
771+ "https://cooking.com/feed", "Cooking", "https://cooking.com", "recipes for dinner")
772+ assert.NilError(t, err)
773+
774+ _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`,
775+ "did:test:newuser", "https://go.com/feed")
776+ assert.NilError(t, err)
777+
778+ engine := newTestEngine(dbs)
779+ assert.NilError(t, engine.ComputeFeedEmbeddings(ctx))
780+
781+ recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10)
782+ assert.NilError(t, err)
783+ assert.Assert(t, len(recs) > 0, "embedding cold start should return recommendations")
784+
785+ for _, r := range recs {
786+ assert.Assert(t, r.FeedURL != "https://go.com/feed", "should not recommend already subscribed feed")
787+ }
788+}
789+
790+func TestNormalizeFeedScores(t *testing.T) {
791+ recs := []*FeedRecommendation{
792+ {FeedURL: "a", Score: 10.0},
793+ {FeedURL: "b", Score: 5.0},
794+ {FeedURL: "c", Score: 1.0},
542 }795 }
796+ normalizeFeedScores(recs)
797+ assert.Equal(t, recs[0].Score, 1.0)
798+ assert.Equal(t, recs[1].Score, 4.0/9.0)
799+ assert.Equal(t, recs[2].Score, 0.0)
543 }800 }
modified internal/cluster/scoring.go +351 -90
@@ -3,6 +3,9 @@ package cluster
33 import (
44 "context"
55 "database/sql"
6+ "fmt"
7+
8+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
69 )
710
811 type FeedRecommendation struct {
@@ -40,6 +43,10 @@ type ArticleRecommendation struct {
4043 Score float64
4144 }
4245
46+// GetFeedRecommendations returns feed recommendations for a user. Users with
47+// fewer than 5 subscriptions get cold-start recommendations (embedding-based
48+// KNN or graph+popular fallback). Results are min-max normalized and
49+// diversity-filtered before returning.
4350 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
4451 subCount := 0
4552 _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
@@ -47,6 +54,7 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
4754 if subCount < 5 {
4855 recs, err := e.ColdStartRecommendations(ctx, userDID, limit*2)
4956 if err == nil && len(recs) > 0 {
57+ normalizeFeedScores(recs)
5058 return ApplyDiversity(recs, limit), nil
5159 }
5260 }
@@ -56,17 +64,39 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
5664 return nil, err
5765 }
5866
67+ normalizeFeedScores(recs)
5968 return ApplyDiversity(recs, limit), nil
6069 }
6170
71+// GetPeopleRecommendations returns similar users based on subscription overlap,
72+// like co-occurrence, tag overlap, and follow relationships. Scores are min-max
73+// normalized on the Jaccard field.
6274 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
63- return e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit)
75+ recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit)
76+ if err != nil {
77+ return nil, err
78+ }
79+ normalizePersonScores(recs)
80+ return recs, nil
6481 }
6582
83+// GetArticleRecommendations returns article recommendations combining social
84+// signals (liked by similar users, followed users' feeds), content similarity
85+// (embedding KNN against user's liked articles), and recency. Scores are
86+// min-max normalized.
6687 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
67- return e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)
88+ recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)
89+ if err != nil {
90+ return nil, err
91+ }
92+ normalizeArticleScores(recs)
93+ return recs, nil
6894 }
6995
96+// SignalWeights holds per-signal multipliers used in the recommendation scoring
97+// formula. Weights are auto-tuned per user via a bandit-style reward/penalty
98+// system (see weights.go). Each field maps to a column in
99+// recs.user_signal_weights.
70100 type SignalWeights struct {
71101 WSub float64
72102 WLike float64
@@ -74,6 +104,7 @@ type SignalWeights struct {
74104 WSocial float64
75105 WPop float64
76106 WCategory float64
107+ WContent float64
77108 }
78109
79110 func defaultWeights() SignalWeights {
@@ -84,6 +115,7 @@ func defaultWeights() SignalWeights {
84115 WSocial: 0.7,
85116 WPop: 0.2,
86117 WCategory: 0.4,
118+ WContent: 0.4,
87119 }
88120 }
89121
@@ -91,9 +123,9 @@ func (e *Engine) GetWeights(ctx context.Context, userDID string) SignalWeights {
91123 w := defaultWeights()
92124 var dbW SignalWeights
93125 err := e.db.QueryRowContext(ctx, `
94- SELECT w_sub, w_like, w_tag, w_social, w_pop, w_category
126+ SELECT w_sub, w_like, w_tag, w_social, w_pop, w_category, w_content
95127 FROM recs.user_signal_weights WHERE user_did = ?
96- `, userDID).Scan(&dbW.WSub, &dbW.WLike, &dbW.WTag, &dbW.WSocial, &dbW.WPop, &dbW.WCategory)
128+ `, userDID).Scan(&dbW.WSub, &dbW.WLike, &dbW.WTag, &dbW.WSocial, &dbW.WPop, &dbW.WCategory, &dbW.WContent)
97129 if err == nil {
98130 return dbW
99131 }
@@ -115,7 +147,7 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID
115147 FROM similar_users su
116148 JOIN articles.subscriptions s ON s.user_did = su.peer
117149 WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
118- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
150+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
119151 GROUP BY s.feed_url
120152 ),
121153 like_signals AS (
@@ -125,17 +157,17 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID
125157 JOIN articles.likes l ON l.author_did = su.peer
126158 JOIN articles.subscriptions s ON s.feed_url = l.feed_url
127159 WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
128- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
160+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
129161 GROUP BY s.feed_url
130162 ),
131163 social_boost AS (
132164 SELECT s.feed_url,
133- SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social
165+ SUM(CASE fd.distance WHEN 1 THEN 1.0 WHEN 2 THEN 0.3 WHEN 3 THEN 0.1 ELSE 0 END) AS social
134166 FROM recs.follow_distances fd
135167 JOIN articles.subscriptions s ON s.user_did = fd.user_b
136168 WHERE fd.user_a = ?
137169 AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
138- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
170+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
139171 GROUP BY s.feed_url
140172 ),
141173 category_counts AS (
@@ -183,10 +215,192 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID
183215 return results, rows.Err()
184216 }
185217
218+func normalizeFeedScores(recs []*FeedRecommendation) {
219+ if len(recs) < 2 {
220+ return
221+ }
222+ min, max := recs[0].Score, recs[0].Score
223+ for _, r := range recs[1:] {
224+ if r.Score < min {
225+ min = r.Score
226+ }
227+ if r.Score > max {
228+ max = r.Score
229+ }
230+ }
231+ if max == min {
232+ return
233+ }
234+ span := max - min
235+ for _, r := range recs {
236+ r.Score = (r.Score - min) / span
237+ }
238+}
239+
240+func normalizeArticleScores(recs []*ArticleRecommendation) {
241+ if len(recs) < 2 {
242+ return
243+ }
244+ min, max := recs[0].Score, recs[0].Score
245+ for _, r := range recs[1:] {
246+ if r.Score < min {
247+ min = r.Score
248+ }
249+ if r.Score > max {
250+ max = r.Score
251+ }
252+ }
253+ if max == min {
254+ return
255+ }
256+ span := max - min
257+ for _, r := range recs {
258+ r.Score = (r.Score - min) / span
259+ }
260+}
261+
262+func normalizePersonScores(recs []*PersonRecommendation) {
263+ if len(recs) < 2 {
264+ return
265+ }
266+ min, max := recs[0].Jaccard, recs[0].Jaccard
267+ for _, r := range recs[1:] {
268+ if r.Jaccard < min {
269+ min = r.Jaccard
270+ }
271+ if r.Jaccard > max {
272+ max = r.Jaccard
273+ }
274+ }
275+ if max == min {
276+ return
277+ }
278+ span := max - min
279+ for _, r := range recs {
280+ r.Jaccard = (r.Jaccard - min) / span
281+ }
282+}
283+
284+func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
285+ if e.embedder == nil {
286+ return nil, nil
287+ }
288+
289+ conn, err := e.db.Conn(ctx)
290+ if err != nil {
291+ return nil, err
292+ }
293+ defer conn.Close()
294+
295+ subRows, err := conn.QueryContext(ctx, `
296+ SELECT fe.feed_url, fe.embedding FROM articles.subscriptions s
297+ JOIN recs.feed_embeddings fe ON fe.feed_url = s.feed_url
298+ WHERE s.user_did = ?
299+ `, userDID)
300+ if err != nil {
301+ return nil, err
302+ }
303+
304+ dim := e.embedder.Dimension()
305+ sumVec := make([]float32, dim)
306+ subCount := 0
307+ var subFeedURLs []string
308+ for subRows.Next() {
309+ var url string
310+ var blob []byte
311+ if err := subRows.Scan(&url, &blob); err != nil {
312+ subRows.Close()
313+ return nil, err
314+ }
315+ v := deserializeFloat32(blob)
316+ if len(v) != dim {
317+ continue
318+ }
319+ for j := range sumVec {
320+ sumVec[j] += v[j]
321+ }
322+ subCount++
323+ subFeedURLs = append(subFeedURLs, url)
324+ }
325+ subRows.Close()
326+
327+ if subCount == 0 {
328+ return nil, nil
329+ }
330+
331+ avgVec := make([]float32, dim)
332+ for j := range avgVec {
333+ avgVec[j] = sumVec[j] / float32(subCount)
334+ }
335+
336+ subSet := make(map[string]bool, len(subFeedURLs))
337+ for _, u := range subFeedURLs {
338+ subSet[u] = true
339+ }
340+
341+ queryBlob, err := vec.SerializeFloat32(avgVec)
342+ if err != nil {
343+ return nil, fmt.Errorf("serialize query vector: %w", err)
344+ }
345+
346+ knnRows, err := conn.QueryContext(ctx, `
347+ SELECT fe.feed_url, fe.distance, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
348+ COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, '')
349+ FROM recs.feed_embeddings fe
350+ JOIN articles.feeds f ON f.feed_url = fe.feed_url
351+ WHERE fe.embedding MATCH ? AND fe.k = ?
352+ ORDER BY fe.distance
353+ `, queryBlob, limit+len(subSet))
354+ if err != nil {
355+ return nil, err
356+ }
357+
358+ var results []*FeedRecommendation
359+ for knnRows.Next() {
360+ var r FeedRecommendation
361+ var dist float64
362+ if err := knnRows.Scan(&r.FeedURL, &dist, &r.Title, &r.SiteURL,
363+ &r.Description, &r.SubscriberCount, &r.FaviconURL); err != nil {
364+ knnRows.Close()
365+ return nil, err
366+ }
367+ if subSet[r.FeedURL] {
368+ continue
369+ }
370+ r.Score = 1.0 - dist
371+ if r.Score <= 0 {
372+ continue
373+ }
374+ results = append(results, &r)
375+ if len(results) >= limit {
376+ break
377+ }
378+ }
379+ knnRows.Close()
380+
381+ return results, nil
382+}
383+
186384 func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
187385 w := e.GetWeights(ctx, userDID)
188386
189- rows, err := e.db.QueryContext(ctx, `
387+ conn, err := e.db.Conn(ctx)
388+ if err != nil {
389+ return nil, err
390+ }
391+ defer conn.Close()
392+
393+ if err := e.ensureContentBoostTable(ctx, conn); err != nil {
394+ return nil, err
395+ }
396+
397+ if e.embedder != nil {
398+ if err := e.populateContentBoost(ctx, conn, userDID); err != nil {
399+ e.logger.Warn("content boost failed", "error", err)
400+ }
401+ }
402+
403+ rows, err := conn.QueryContext(ctx, `
190404 WITH similar_users AS (
191405 SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15
192406 UNION ALL
@@ -201,13 +415,13 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
201415 SELECT 1 FROM articles.likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
202416 )
203417 AND NOT EXISTS (
204- SELECT 1 FROM recs.dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url
418+ SELECT 1 FROM main.dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url
205419 )
206420 GROUP BY l.feed_url, l.article_url
207421 ),
208422 social_likes AS (
209423 SELECT l.feed_url, l.article_url,
210- SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social
424+ SUM(CASE fd.distance WHEN 1 THEN 1.0 WHEN 2 THEN 0.3 WHEN 3 THEN 0.1 ELSE 0 END) AS social
211425 FROM recs.follow_distances fd
212426 JOIN articles.likes l ON l.author_did = fd.user_b
213427 WHERE fd.user_a = ?
@@ -222,17 +436,20 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
222436 COALESCE(rs.is_read, 0),
223437 COALESCE(la.like_signal, 0) * ?
224438 + COALESCE(sl.social, 0) * ?
439+ + COALESCE(cb.score, 0) * ?
225440 + EXP(-0.023 * CAST(julianday('now') - julianday(a.published) AS REAL)) * 0.2
226441 AS score
227442 FROM liked_articles la
228443 JOIN articles.articles a ON a.feed_url = la.feed_url AND a.url = la.article_url
229444 LEFT JOIN articles.feeds f ON f.feed_url = la.feed_url
230445 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url
446+ LEFT JOIN _content_boost cb ON cb.article_id = a.id
231447 LEFT JOIN articles.read_state rs ON rs.article_id = a.id AND rs.user_did = ?
232448 WHERE COALESCE(rs.is_read, 0) = 0
233449 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC
234450 LIMIT ?
235- `, userDID, userDID, userDID, userDID, userDID, userDID, w.WLike, w.WSocial, userDID, limit)
451+ `, userDID, userDID, userDID, userDID, userDID, userDID,
452+ w.WLike, w.WSocial, w.WContent, userDID, limit)
236453 if err != nil {
237454 return nil, err
238455 }
@@ -282,92 +499,124 @@ func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userD
282499 }
283500
284501 func (e *Engine) ComputeSignalProfiles(ctx context.Context) error {
285- tx, err := e.db.BeginTx(ctx, nil)
502+ conn, err := e.db.Conn(ctx)
286503 if err != nil {
287504 return err
288505 }
289- defer func() { _ = tx.Rollback() }()
506+ defer conn.Close()
290507
291- if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_signal_profiles`); err != nil {
292- return err
293- }
508+ {
509+ tx, err := conn.BeginTx(ctx, nil)
510+ if err != nil {
511+ return err
512+ }
513+ defer func() { _ = tx.Rollback() }()
294514
295- if _, err := tx.ExecContext(ctx, `
296- CREATE TEMP TABLE IF NOT EXISTS _user_like_counts (user_did TEXT PRIMARY KEY, cnt INT)
297- `); err != nil {
298- return err
299- }
300- if _, err := tx.ExecContext(ctx, `DELETE FROM _user_like_counts`); err != nil {
301- return err
302- }
303- if _, err := tx.ExecContext(ctx, `
304- INSERT INTO _user_like_counts SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
305- `); err != nil {
306- return err
307- }
515+ if _, err := tx.ExecContext(ctx, `
516+ CREATE TEMP TABLE IF NOT EXISTS _user_like_counts (user_did TEXT PRIMARY KEY, cnt INT)
517+ `); err != nil {
518+ return err
519+ }
520+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_like_counts`); err != nil {
521+ return err
522+ }
523+ if _, err := tx.ExecContext(ctx, `
524+ INSERT INTO _user_like_counts SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
525+ `); err != nil {
526+ return err
527+ }
308528
309- if _, err := tx.ExecContext(ctx, `
310- CREATE TEMP TABLE IF NOT EXISTS _user_tag_counts (user_did TEXT PRIMARY KEY, cnt INT)
311- `); err != nil {
312- return err
313- }
314- if _, err := tx.ExecContext(ctx, `DELETE FROM _user_tag_counts`); err != nil {
315- return err
316- }
317- if _, err := tx.ExecContext(ctx, `
318- INSERT INTO _user_tag_counts
319- WITH user_tags AS (
320- SELECT author_did, TRIM(value) AS tag
321- FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
322- WHERE tags IS NOT NULL AND tags != ''
323- )
324- SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
325- `); err != nil {
326- return err
327- }
529+ if _, err := tx.ExecContext(ctx, `
530+ CREATE TEMP TABLE IF NOT EXISTS _user_tag_counts (user_did TEXT PRIMARY KEY, cnt INT)
531+ `); err != nil {
532+ return err
533+ }
534+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_tag_counts`); err != nil {
535+ return err
536+ }
537+ if _, err := tx.ExecContext(ctx, `
538+ INSERT INTO _user_tag_counts
539+ WITH user_tags AS (
540+ SELECT author_did, TRIM(value) AS tag
541+ FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
542+ WHERE tags IS NOT NULL AND tags != ''
543+ )
544+ SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
545+ `); err != nil {
546+ return err
547+ }
328548
329- if _, err := tx.ExecContext(ctx, `
330- CREATE TEMP TABLE IF NOT EXISTS _user_top_categories (user_did TEXT PRIMARY KEY, categories TEXT)
331- `); err != nil {
332- return err
333- }
334- if _, err := tx.ExecContext(ctx, `DELETE FROM _user_top_categories`); err != nil {
335- return err
336- }
337- if _, err := tx.ExecContext(ctx, `
338- INSERT INTO _user_top_categories
339- SELECT user_did, '[' || GROUP_CONCAT('{"c":"' || category || '","n":"' || CAST(cnt AS TEXT) || '}') || ']'
340- FROM (
341- SELECT user_did, category, COUNT(*) AS cnt
342- FROM articles.subscriptions
343- WHERE category IS NOT NULL AND category != ''
344- GROUP BY user_did, category
345- ORDER BY COUNT(*) DESC
346- LIMIT 5
347- )
348- GROUP BY user_did
349- `); err != nil {
350- return err
351- }
549+ if _, err := tx.ExecContext(ctx, `
550+ CREATE TEMP TABLE IF NOT EXISTS _user_top_categories (user_did TEXT PRIMARY KEY, categories TEXT)
551+ `); err != nil {
552+ return err
553+ }
554+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_top_categories`); err != nil {
555+ return err
556+ }
557+ if _, err := tx.ExecContext(ctx, `
558+ INSERT INTO _user_top_categories
559+ SELECT user_did, '[' || GROUP_CONCAT('{"c":"' || category || '","n":"' || CAST(cnt AS TEXT) || '}') || ']'
560+ FROM (
561+ SELECT user_did, category, COUNT(*) AS cnt
562+ FROM articles.subscriptions
563+ WHERE category IS NOT NULL AND category != ''
564+ GROUP BY user_did, category
565+ ORDER BY COUNT(*) DESC
566+ LIMIT 5
567+ )
568+ GROUP BY user_did
569+ `); err != nil {
570+ return err
571+ }
352572
353- _, err = tx.ExecContext(ctx, `
354- INSERT INTO recs.user_signal_profiles (user_did, total_likes, total_tags, top_categories)
355- SELECT
356- u.did,
357- COALESCE(lc.cnt, 0),
358- COALESCE(tc.cnt, 0),
359- COALESCE(cc.categories, '[]')
360- FROM main.users u
361- LEFT JOIN _user_like_counts lc ON lc.user_did = u.did
362- LEFT JOIN _user_tag_counts tc ON tc.user_did = u.did
363- LEFT JOIN _user_top_categories cc ON cc.user_did = u.did
364- `)
365- if err != nil {
366- return err
573+ if _, err := tx.ExecContext(ctx, `
574+ CREATE TEMP TABLE IF NOT EXISTS _signal_profiles_staging (
575+ user_did TEXT PRIMARY KEY, total_likes INT, total_tags INT, top_categories TEXT
576+ )
577+ `); err != nil {
578+ return err
579+ }
580+ if _, err := tx.ExecContext(ctx, `DELETE FROM _signal_profiles_staging`); err != nil {
581+ return err
582+ }
583+ if _, err := tx.ExecContext(ctx, `
584+ INSERT INTO _signal_profiles_staging (user_did, total_likes, total_tags, top_categories)
585+ SELECT
586+ u.did,
587+ COALESCE(lc.cnt, 0),
588+ COALESCE(tc.cnt, 0),
589+ COALESCE(cc.categories, '[]')
590+ FROM main.users u
591+ LEFT JOIN _user_like_counts lc ON lc.user_did = u.did
592+ LEFT JOIN _user_tag_counts tc ON tc.user_did = u.did
593+ LEFT JOIN _user_top_categories cc ON cc.user_did = u.did
594+ `); err != nil {
595+ return err
596+ }
597+
598+ if err := tx.Commit(); err != nil {
599+ return err
600+ }
367601 }
368602
369- e.logger.Info("signal profiles computed")
370- return tx.Commit()
603+ {
604+ tx, err := conn.BeginTx(ctx, nil)
605+ if err != nil {
606+ return err
607+ }
608+ defer func() { _ = tx.Rollback() }()
609+
610+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_signal_profiles`); err != nil {
611+ return err
612+ }
613+ if _, err := tx.ExecContext(ctx, `INSERT INTO recs.user_signal_profiles (user_did, total_likes, total_tags, top_categories) SELECT user_did, total_likes, total_tags, top_categories FROM _signal_profiles_staging`); err != nil {
614+ return err
615+ }
616+
617+ e.logger.Info("signal profiles computed")
618+ return tx.Commit()
619+ }
371620 }
372621
373622 func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
@@ -377,6 +626,18 @@ func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, l
377626 return nil, nil
378627 }
379628
629+ recs, err := e.coldStartFromEmbeddings(ctx, userDID, limit)
630+ if err != nil {
631+ e.logger.Warn("embedding cold start failed", "error", err)
632+ }
633+ if len(recs) > 0 {
634+ return recs, nil
635+ }
636+
637+ return e.coldStartFromGraphAndPopular(ctx, userDID, limit)
638+}
639+
640+func (e *Engine) coldStartFromGraphAndPopular(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
380641 rows, err := e.db.QueryContext(ctx, `
381642 WITH followed_feeds AS (
382643 SELECT s.feed_url, 1.0 AS weight
@@ -384,7 +645,7 @@ func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, l
384645 JOIN articles.subscriptions s ON s.user_did = fd.user_b
385646 WHERE fd.user_a = ? AND fd.distance = 1
386647 AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
387- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
648+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
388649 ),
389650 popular_feeds AS (
390651 SELECT feed_url, subscriber_count,
@@ -392,7 +653,7 @@ func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, l
392653 FROM articles.feeds
393654 WHERE subscriber_count > 0
394655 AND feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
395- AND feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
656+ AND feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
396657 ORDER BY subscriber_count DESC
397658 LIMIT 50
398659 ),
@@ -3,6 +3,9 @@ package cluster
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6+ "fmt"
7+
8+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
6 )9 )
7 10
8 type FeedRecommendation struct {11 type FeedRecommendation struct {
@@ -40,6 +43,10 @@ type ArticleRecommendation struct {
40 Score float6443 Score float64
41 }44 }
42 45
46+// GetFeedRecommendations returns feed recommendations for a user. Users with
47+// fewer than 5 subscriptions get cold-start recommendations (embedding-based
48+// KNN or graph+popular fallback). Results are min-max normalized and
49+// diversity-filtered before returning.
43 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {50 func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
44 subCount := 051 subCount := 0
45 _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)52 _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
@@ -47,6 +54,7 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
47 if subCount < 5 {54 if subCount < 5 {
48 recs, err := e.ColdStartRecommendations(ctx, userDID, limit*2)55 recs, err := e.ColdStartRecommendations(ctx, userDID, limit*2)
49 if err == nil && len(recs) > 0 {56 if err == nil && len(recs) > 0 {
57+ normalizeFeedScores(recs)
50 return ApplyDiversity(recs, limit), nil58 return ApplyDiversity(recs, limit), nil
51 }59 }
52 }60 }
@@ -56,17 +64,39 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
56 return nil, err64 return nil, err
57 }65 }
58 66
67+ normalizeFeedScores(recs)
59 return ApplyDiversity(recs, limit), nil68 return ApplyDiversity(recs, limit), nil
60 }69 }
61 70
71+// GetPeopleRecommendations returns similar users based on subscription overlap,
72+// like co-occurrence, tag overlap, and follow relationships. Scores are min-max
73+// normalized on the Jaccard field.
62 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {74 func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
63- return e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit)75+ recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit)
76+ if err != nil {
77+ return nil, err
78+ }
79+ normalizePersonScores(recs)
80+ return recs, nil
64 }81 }
65 82
83+// GetArticleRecommendations returns article recommendations combining social
84+// signals (liked by similar users, followed users' feeds), content similarity
85+// (embedding KNN against user's liked articles), and recency. Scores are
86+// min-max normalized.
66 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {87 func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
67- return e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)88+ recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)
89+ if err != nil {
90+ return nil, err
91+ }
92+ normalizeArticleScores(recs)
93+ return recs, nil
68 }94 }
69 95
96+// SignalWeights holds per-signal multipliers used in the recommendation scoring
97+// formula. Weights are auto-tuned per user via a bandit-style reward/penalty
98+// system (see weights.go). Each field maps to a column in
99+// recs.user_signal_weights.
70 type SignalWeights struct {100 type SignalWeights struct {
71 WSub float64101 WSub float64
72 WLike float64102 WLike float64
@@ -74,6 +104,7 @@ type SignalWeights struct {
74 WSocial float64104 WSocial float64
75 WPop float64105 WPop float64
76 WCategory float64106 WCategory float64
107+ WContent float64
77 }108 }
78 109
79 func defaultWeights() SignalWeights {110 func defaultWeights() SignalWeights {
@@ -84,6 +115,7 @@ func defaultWeights() SignalWeights {
84 WSocial: 0.7,115 WSocial: 0.7,
85 WPop: 0.2,116 WPop: 0.2,
86 WCategory: 0.4,117 WCategory: 0.4,
118+ WContent: 0.4,
87 }119 }
88 }120 }
89 121
@@ -91,9 +123,9 @@ func (e *Engine) GetWeights(ctx context.Context, userDID string) SignalWeights {
91 w := defaultWeights()123 w := defaultWeights()
92 var dbW SignalWeights124 var dbW SignalWeights
93 err := e.db.QueryRowContext(ctx, `125 err := e.db.QueryRowContext(ctx, `
94- SELECT w_sub, w_like, w_tag, w_social, w_pop, w_category126+ SELECT w_sub, w_like, w_tag, w_social, w_pop, w_category, w_content
95 FROM recs.user_signal_weights WHERE user_did = ?127 FROM recs.user_signal_weights WHERE user_did = ?
96- `, userDID).Scan(&dbW.WSub, &dbW.WLike, &dbW.WTag, &dbW.WSocial, &dbW.WPop, &dbW.WCategory)128+ `, userDID).Scan(&dbW.WSub, &dbW.WLike, &dbW.WTag, &dbW.WSocial, &dbW.WPop, &dbW.WCategory, &dbW.WContent)
97 if err == nil {129 if err == nil {
98 return dbW130 return dbW
99 }131 }
@@ -115,7 +147,7 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID
115 FROM similar_users su147 FROM similar_users su
116 JOIN articles.subscriptions s ON s.user_did = su.peer148 JOIN articles.subscriptions s ON s.user_did = su.peer
117 WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)149 WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
118- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')150+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
119 GROUP BY s.feed_url151 GROUP BY s.feed_url
120 ),152 ),
121 like_signals AS (153 like_signals AS (
@@ -125,17 +157,17 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID
125 JOIN articles.likes l ON l.author_did = su.peer157 JOIN articles.likes l ON l.author_did = su.peer
126 JOIN articles.subscriptions s ON s.feed_url = l.feed_url158 JOIN articles.subscriptions s ON s.feed_url = l.feed_url
127 WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)159 WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
128- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')160+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
129 GROUP BY s.feed_url161 GROUP BY s.feed_url
130 ),162 ),
131 social_boost AS (163 social_boost AS (
132 SELECT s.feed_url,164 SELECT s.feed_url,
133- SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social165+ SUM(CASE fd.distance WHEN 1 THEN 1.0 WHEN 2 THEN 0.3 WHEN 3 THEN 0.1 ELSE 0 END) AS social
134 FROM recs.follow_distances fd166 FROM recs.follow_distances fd
135 JOIN articles.subscriptions s ON s.user_did = fd.user_b167 JOIN articles.subscriptions s ON s.user_did = fd.user_b
136 WHERE fd.user_a = ?168 WHERE fd.user_a = ?
137 AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)169 AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
138- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')170+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
139 GROUP BY s.feed_url171 GROUP BY s.feed_url
140 ),172 ),
141 category_counts AS (173 category_counts AS (
@@ -183,10 +215,192 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID
183 return results, rows.Err()215 return results, rows.Err()
184 }216 }
185 217
218+func normalizeFeedScores(recs []*FeedRecommendation) {
219+ if len(recs) < 2 {
220+ return
221+ }
222+ min, max := recs[0].Score, recs[0].Score
223+ for _, r := range recs[1:] {
224+ if r.Score < min {
225+ min = r.Score
226+ }
227+ if r.Score > max {
228+ max = r.Score
229+ }
230+ }
231+ if max == min {
232+ return
233+ }
234+ span := max - min
235+ for _, r := range recs {
236+ r.Score = (r.Score - min) / span
237+ }
238+}
239+
240+func normalizeArticleScores(recs []*ArticleRecommendation) {
241+ if len(recs) < 2 {
242+ return
243+ }
244+ min, max := recs[0].Score, recs[0].Score
245+ for _, r := range recs[1:] {
246+ if r.Score < min {
247+ min = r.Score
248+ }
249+ if r.Score > max {
250+ max = r.Score
251+ }
252+ }
253+ if max == min {
254+ return
255+ }
256+ span := max - min
257+ for _, r := range recs {
258+ r.Score = (r.Score - min) / span
259+ }
260+}
261+
262+func normalizePersonScores(recs []*PersonRecommendation) {
263+ if len(recs) < 2 {
264+ return
265+ }
266+ min, max := recs[0].Jaccard, recs[0].Jaccard
267+ for _, r := range recs[1:] {
268+ if r.Jaccard < min {
269+ min = r.Jaccard
270+ }
271+ if r.Jaccard > max {
272+ max = r.Jaccard
273+ }
274+ }
275+ if max == min {
276+ return
277+ }
278+ span := max - min
279+ for _, r := range recs {
280+ r.Jaccard = (r.Jaccard - min) / span
281+ }
282+}
283+
284+func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
285+ if e.embedder == nil {
286+ return nil, nil
287+ }
288+
289+ conn, err := e.db.Conn(ctx)
290+ if err != nil {
291+ return nil, err
292+ }
293+ defer conn.Close()
294+
295+ subRows, err := conn.QueryContext(ctx, `
296+ SELECT fe.feed_url, fe.embedding FROM articles.subscriptions s
297+ JOIN recs.feed_embeddings fe ON fe.feed_url = s.feed_url
298+ WHERE s.user_did = ?
299+ `, userDID)
300+ if err != nil {
301+ return nil, err
302+ }
303+
304+ dim := e.embedder.Dimension()
305+ sumVec := make([]float32, dim)
306+ subCount := 0
307+ var subFeedURLs []string
308+ for subRows.Next() {
309+ var url string
310+ var blob []byte
311+ if err := subRows.Scan(&url, &blob); err != nil {
312+ subRows.Close()
313+ return nil, err
314+ }
315+ v := deserializeFloat32(blob)
316+ if len(v) != dim {
317+ continue
318+ }
319+ for j := range sumVec {
320+ sumVec[j] += v[j]
321+ }
322+ subCount++
323+ subFeedURLs = append(subFeedURLs, url)
324+ }
325+ subRows.Close()
326+
327+ if subCount == 0 {
328+ return nil, nil
329+ }
330+
331+ avgVec := make([]float32, dim)
332+ for j := range avgVec {
333+ avgVec[j] = sumVec[j] / float32(subCount)
334+ }
335+
336+ subSet := make(map[string]bool, len(subFeedURLs))
337+ for _, u := range subFeedURLs {
338+ subSet[u] = true
339+ }
340+
341+ queryBlob, err := vec.SerializeFloat32(avgVec)
342+ if err != nil {
343+ return nil, fmt.Errorf("serialize query vector: %w", err)
344+ }
345+
346+ knnRows, err := conn.QueryContext(ctx, `
347+ SELECT fe.feed_url, fe.distance, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
348+ COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, '')
349+ FROM recs.feed_embeddings fe
350+ JOIN articles.feeds f ON f.feed_url = fe.feed_url
351+ WHERE fe.embedding MATCH ? AND fe.k = ?
352+ ORDER BY fe.distance
353+ `, queryBlob, limit+len(subSet))
354+ if err != nil {
355+ return nil, err
356+ }
357+
358+ var results []*FeedRecommendation
359+ for knnRows.Next() {
360+ var r FeedRecommendation
361+ var dist float64
362+ if err := knnRows.Scan(&r.FeedURL, &dist, &r.Title, &r.SiteURL,
363+ &r.Description, &r.SubscriberCount, &r.FaviconURL); err != nil {
364+ knnRows.Close()
365+ return nil, err
366+ }
367+ if subSet[r.FeedURL] {
368+ continue
369+ }
370+ r.Score = 1.0 - dist
371+ if r.Score <= 0 {
372+ continue
373+ }
374+ results = append(results, &r)
375+ if len(results) >= limit {
376+ break
377+ }
378+ }
379+ knnRows.Close()
380+
381+ return results, nil
382+}
383+
186 func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {384 func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
187 w := e.GetWeights(ctx, userDID)385 w := e.GetWeights(ctx, userDID)
188 386
189- rows, err := e.db.QueryContext(ctx, `387+ conn, err := e.db.Conn(ctx)
388+ if err != nil {
389+ return nil, err
390+ }
391+ defer conn.Close()
392+
393+ if err := e.ensureContentBoostTable(ctx, conn); err != nil {
394+ return nil, err
395+ }
396+
397+ if e.embedder != nil {
398+ if err := e.populateContentBoost(ctx, conn, userDID); err != nil {
399+ e.logger.Warn("content boost failed", "error", err)
400+ }
401+ }
402+
403+ rows, err := conn.QueryContext(ctx, `
190 WITH similar_users AS (404 WITH similar_users AS (
191 SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15405 SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15
192 UNION ALL406 UNION ALL
@@ -201,13 +415,13 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
201 SELECT 1 FROM articles.likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url415 SELECT 1 FROM articles.likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
202 )416 )
203 AND NOT EXISTS (417 AND NOT EXISTS (
204- SELECT 1 FROM recs.dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url418+ SELECT 1 FROM main.dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url
205 )419 )
206 GROUP BY l.feed_url, l.article_url420 GROUP BY l.feed_url, l.article_url
207 ),421 ),
208 social_likes AS (422 social_likes AS (
209 SELECT l.feed_url, l.article_url,423 SELECT l.feed_url, l.article_url,
210- SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social424+ SUM(CASE fd.distance WHEN 1 THEN 1.0 WHEN 2 THEN 0.3 WHEN 3 THEN 0.1 ELSE 0 END) AS social
211 FROM recs.follow_distances fd425 FROM recs.follow_distances fd
212 JOIN articles.likes l ON l.author_did = fd.user_b426 JOIN articles.likes l ON l.author_did = fd.user_b
213 WHERE fd.user_a = ?427 WHERE fd.user_a = ?
@@ -222,17 +436,20 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
222 COALESCE(rs.is_read, 0),436 COALESCE(rs.is_read, 0),
223 COALESCE(la.like_signal, 0) * ?437 COALESCE(la.like_signal, 0) * ?
224 + COALESCE(sl.social, 0) * ?438 + COALESCE(sl.social, 0) * ?
439+ + COALESCE(cb.score, 0) * ?
225 + EXP(-0.023 * CAST(julianday('now') - julianday(a.published) AS REAL)) * 0.2440 + EXP(-0.023 * CAST(julianday('now') - julianday(a.published) AS REAL)) * 0.2
226 AS score441 AS score
227 FROM liked_articles la442 FROM liked_articles la
228 JOIN articles.articles a ON a.feed_url = la.feed_url AND a.url = la.article_url443 JOIN articles.articles a ON a.feed_url = la.feed_url AND a.url = la.article_url
229 LEFT JOIN articles.feeds f ON f.feed_url = la.feed_url444 LEFT JOIN articles.feeds f ON f.feed_url = la.feed_url
230 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url445 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url
446+ LEFT JOIN _content_boost cb ON cb.article_id = a.id
231 LEFT JOIN articles.read_state rs ON rs.article_id = a.id AND rs.user_did = ?447 LEFT JOIN articles.read_state rs ON rs.article_id = a.id AND rs.user_did = ?
232 WHERE COALESCE(rs.is_read, 0) = 0448 WHERE COALESCE(rs.is_read, 0) = 0
233 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC449 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC
234 LIMIT ?450 LIMIT ?
235- `, userDID, userDID, userDID, userDID, userDID, userDID, w.WLike, w.WSocial, userDID, limit)451+ `, userDID, userDID, userDID, userDID, userDID, userDID,
452+ w.WLike, w.WSocial, w.WContent, userDID, limit)
236 if err != nil {453 if err != nil {
237 return nil, err454 return nil, err
238 }455 }
@@ -282,92 +499,124 @@ func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userD
282 }499 }
283 500
284 func (e *Engine) ComputeSignalProfiles(ctx context.Context) error {501 func (e *Engine) ComputeSignalProfiles(ctx context.Context) error {
285- tx, err := e.db.BeginTx(ctx, nil)502+ conn, err := e.db.Conn(ctx)
286 if err != nil {503 if err != nil {
287 return err504 return err
288 }505 }
289- defer func() { _ = tx.Rollback() }()506+ defer conn.Close()
290 507
291- if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_signal_profiles`); err != nil {508+ {
292- return err509+ tx, err := conn.BeginTx(ctx, nil)
293- }510+ if err != nil {
511+ return err
512+ }
513+ defer func() { _ = tx.Rollback() }()
294 514
295- if _, err := tx.ExecContext(ctx, `515+ if _, err := tx.ExecContext(ctx, `
296- CREATE TEMP TABLE IF NOT EXISTS _user_like_counts (user_did TEXT PRIMARY KEY, cnt INT)516+ CREATE TEMP TABLE IF NOT EXISTS _user_like_counts (user_did TEXT PRIMARY KEY, cnt INT)
297- `); err != nil {517+ `); err != nil {
298- return err518+ return err
299- }519+ }
300- if _, err := tx.ExecContext(ctx, `DELETE FROM _user_like_counts`); err != nil {520+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_like_counts`); err != nil {
301- return err521+ return err
302- }522+ }
303- if _, err := tx.ExecContext(ctx, `523+ if _, err := tx.ExecContext(ctx, `
304- INSERT INTO _user_like_counts SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did524+ INSERT INTO _user_like_counts SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
305- `); err != nil {525+ `); err != nil {
306- return err526+ return err
307- }527+ }
308 528
309- if _, err := tx.ExecContext(ctx, `529+ if _, err := tx.ExecContext(ctx, `
310- CREATE TEMP TABLE IF NOT EXISTS _user_tag_counts (user_did TEXT PRIMARY KEY, cnt INT)530+ CREATE TEMP TABLE IF NOT EXISTS _user_tag_counts (user_did TEXT PRIMARY KEY, cnt INT)
311- `); err != nil {531+ `); err != nil {
312- return err532+ return err
313- }533+ }
314- if _, err := tx.ExecContext(ctx, `DELETE FROM _user_tag_counts`); err != nil {534+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_tag_counts`); err != nil {
315- return err535+ return err
316- }536+ }
317- if _, err := tx.ExecContext(ctx, `537+ if _, err := tx.ExecContext(ctx, `
318- INSERT INTO _user_tag_counts538+ INSERT INTO _user_tag_counts
319- WITH user_tags AS (539+ WITH user_tags AS (
320- SELECT author_did, TRIM(value) AS tag540+ SELECT author_did, TRIM(value) AS tag
321- FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')541+ FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
322- WHERE tags IS NOT NULL AND tags != ''542+ WHERE tags IS NOT NULL AND tags != ''
323- )543+ )
324- SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did544+ SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
325- `); err != nil {545+ `); err != nil {
326- return err546+ return err
327- }547+ }
328 548
329- if _, err := tx.ExecContext(ctx, `549+ if _, err := tx.ExecContext(ctx, `
330- CREATE TEMP TABLE IF NOT EXISTS _user_top_categories (user_did TEXT PRIMARY KEY, categories TEXT)550+ CREATE TEMP TABLE IF NOT EXISTS _user_top_categories (user_did TEXT PRIMARY KEY, categories TEXT)
331- `); err != nil {551+ `); err != nil {
332- return err552+ return err
333- }553+ }
334- if _, err := tx.ExecContext(ctx, `DELETE FROM _user_top_categories`); err != nil {554+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_top_categories`); err != nil {
335- return err555+ return err
336- }556+ }
337- if _, err := tx.ExecContext(ctx, `557+ if _, err := tx.ExecContext(ctx, `
338- INSERT INTO _user_top_categories558+ INSERT INTO _user_top_categories
339- SELECT user_did, '[' || GROUP_CONCAT('{"c":"' || category || '","n":"' || CAST(cnt AS TEXT) || '}') || ']'559+ SELECT user_did, '[' || GROUP_CONCAT('{"c":"' || category || '","n":"' || CAST(cnt AS TEXT) || '}') || ']'
340- FROM (560+ FROM (
341- SELECT user_did, category, COUNT(*) AS cnt561+ SELECT user_did, category, COUNT(*) AS cnt
342- FROM articles.subscriptions562+ FROM articles.subscriptions
343- WHERE category IS NOT NULL AND category != ''563+ WHERE category IS NOT NULL AND category != ''
344- GROUP BY user_did, category564+ GROUP BY user_did, category
345- ORDER BY COUNT(*) DESC565+ ORDER BY COUNT(*) DESC
346- LIMIT 5566+ LIMIT 5
347- )567+ )
348- GROUP BY user_did568+ GROUP BY user_did
349- `); err != nil {569+ `); err != nil {
350- return err570+ return err
351- }571+ }
352 572
353- _, err = tx.ExecContext(ctx, `573+ if _, err := tx.ExecContext(ctx, `
354- INSERT INTO recs.user_signal_profiles (user_did, total_likes, total_tags, top_categories)574+ CREATE TEMP TABLE IF NOT EXISTS _signal_profiles_staging (
355- SELECT575+ user_did TEXT PRIMARY KEY, total_likes INT, total_tags INT, top_categories TEXT
356- u.did,576+ )
357- COALESCE(lc.cnt, 0),577+ `); err != nil {
358- COALESCE(tc.cnt, 0),578+ return err
359- COALESCE(cc.categories, '[]')579+ }
360- FROM main.users u580+ if _, err := tx.ExecContext(ctx, `DELETE FROM _signal_profiles_staging`); err != nil {
361- LEFT JOIN _user_like_counts lc ON lc.user_did = u.did581+ return err
362- LEFT JOIN _user_tag_counts tc ON tc.user_did = u.did582+ }
363- LEFT JOIN _user_top_categories cc ON cc.user_did = u.did583+ if _, err := tx.ExecContext(ctx, `
364- `)584+ INSERT INTO _signal_profiles_staging (user_did, total_likes, total_tags, top_categories)
365- if err != nil {585+ SELECT
366- return err586+ u.did,
587+ COALESCE(lc.cnt, 0),
588+ COALESCE(tc.cnt, 0),
589+ COALESCE(cc.categories, '[]')
590+ FROM main.users u
591+ LEFT JOIN _user_like_counts lc ON lc.user_did = u.did
592+ LEFT JOIN _user_tag_counts tc ON tc.user_did = u.did
593+ LEFT JOIN _user_top_categories cc ON cc.user_did = u.did
594+ `); err != nil {
595+ return err
596+ }
597+
598+ if err := tx.Commit(); err != nil {
599+ return err
600+ }
367 }601 }
368 602
369- e.logger.Info("signal profiles computed")603+ {
370- return tx.Commit()604+ tx, err := conn.BeginTx(ctx, nil)
605+ if err != nil {
606+ return err
607+ }
608+ defer func() { _ = tx.Rollback() }()
609+
610+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_signal_profiles`); err != nil {
611+ return err
612+ }
613+ if _, err := tx.ExecContext(ctx, `INSERT INTO recs.user_signal_profiles (user_did, total_likes, total_tags, top_categories) SELECT user_did, total_likes, total_tags, top_categories FROM _signal_profiles_staging`); err != nil {
614+ return err
615+ }
616+
617+ e.logger.Info("signal profiles computed")
618+ return tx.Commit()
619+ }
371 }620 }
372 621
373 func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {622 func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
@@ -377,6 +626,18 @@ func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, l
377 return nil, nil626 return nil, nil
378 }627 }
379 628
629+ recs, err := e.coldStartFromEmbeddings(ctx, userDID, limit)
630+ if err != nil {
631+ e.logger.Warn("embedding cold start failed", "error", err)
632+ }
633+ if len(recs) > 0 {
634+ return recs, nil
635+ }
636+
637+ return e.coldStartFromGraphAndPopular(ctx, userDID, limit)
638+}
639+
640+func (e *Engine) coldStartFromGraphAndPopular(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
380 rows, err := e.db.QueryContext(ctx, `641 rows, err := e.db.QueryContext(ctx, `
381 WITH followed_feeds AS (642 WITH followed_feeds AS (
382 SELECT s.feed_url, 1.0 AS weight643 SELECT s.feed_url, 1.0 AS weight
@@ -384,7 +645,7 @@ func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, l
384 JOIN articles.subscriptions s ON s.user_did = fd.user_b645 JOIN articles.subscriptions s ON s.user_did = fd.user_b
385 WHERE fd.user_a = ? AND fd.distance = 1646 WHERE fd.user_a = ? AND fd.distance = 1
386 AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)647 AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
387- AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')648+ AND s.feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
388 ),649 ),
389 popular_feeds AS (650 popular_feeds AS (
390 SELECT feed_url, subscriber_count,651 SELECT feed_url, subscriber_count,
@@ -392,7 +653,7 @@ func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, l
392 FROM articles.feeds653 FROM articles.feeds
393 WHERE subscriber_count > 0654 WHERE subscriber_count > 0
394 AND feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)655 AND feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
395- AND feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')656+ AND feed_url NOT IN (SELECT target_id FROM main.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
396 ORDER BY subscriber_count DESC657 ORDER BY subscriber_count DESC
397 LIMIT 50658 LIMIT 50
398 ),659 ),
modified internal/cluster/social.go +128 -25
@@ -2,9 +2,69 @@ package cluster
22
33 import (
44 "context"
5+ "fmt"
56 )
67
7-func (e *Engine) ComputeFollowDistances(ctx context.Context) error {
8+const maxFollowDepth = 3
9+
10+type followDistance struct {
11+ userA string
12+ userB string
13+ distance int
14+}
15+
16+func (e *Engine) ComputeFollowDistancesData(ctx context.Context, sources []string) ([]followDistance, error) {
17+ if len(sources) == 0 {
18+ return nil, nil
19+ }
20+
21+ rows, err := e.db.QueryContext(ctx, `SELECT user_did, target_did FROM main.follows WHERE user_did != target_did`)
22+ if err != nil {
23+ return nil, err
24+ }
25+ defer rows.Close()
26+
27+ adj := make(map[string][]string)
28+ for rows.Next() {
29+ var src, dst string
30+ if err := rows.Scan(&src, &dst); err != nil {
31+ return nil, err
32+ }
33+ adj[src] = append(adj[src], dst)
34+ }
35+ if err := rows.Err(); err != nil {
36+ return nil, err
37+ }
38+
39+ var result []followDistance
40+ for _, src := range sources {
41+ dist := map[string]int{src: 0}
42+ queue := []string{src}
43+ for len(queue) > 0 {
44+ cur := queue[0]
45+ queue = queue[1:]
46+ d := dist[cur]
47+ if d >= maxFollowDepth {
48+ continue
49+ }
50+ for _, next := range adj[cur] {
51+ if _, ok := dist[next]; !ok {
52+ dist[next] = d + 1
53+ queue = append(queue, next)
54+ }
55+ }
56+ }
57+ for other, d := range dist {
58+ if d > 0 {
59+ result = append(result, followDistance{userA: src, userB: other, distance: d})
60+ }
61+ }
62+ }
63+
64+ return result, nil
65+}
66+
67+func (e *Engine) WriteFollowDistances(ctx context.Context, distances []followDistance) error {
868 tx, err := e.db.BeginTx(ctx, nil)
969 if err != nil {
1070 return err
@@ -15,45 +75,88 @@ func (e *Engine) ComputeFollowDistances(ctx context.Context) error {
1575 return err
1676 }
1777
18- _, err = tx.ExecContext(ctx, `
19- INSERT INTO recs.follow_distances (user_a, user_b, distance)
20- SELECT user_a, user_b, MIN(distance) FROM (
21- SELECT user_did AS user_a, target_did AS user_b, 1 AS distance FROM main.follows WHERE user_did != target_did
22- UNION ALL
23- SELECT f1.user_did, f2.target_did, 2
24- FROM main.follows f1
25- JOIN main.follows f2 ON f1.target_did = f2.user_did
26- WHERE f1.user_did != f2.target_did
27- ) GROUP BY user_a, user_b
28- `)
78+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO recs.follow_distances (user_a, user_b, distance) VALUES (?, ?, ?)`)
2979 if err != nil {
3080 return err
3181 }
82+ defer stmt.Close()
3283
33- e.logger.Info("follow distances computed")
84+ for _, d := range distances {
85+ if _, err := stmt.ExecContext(ctx, d.userA, d.userB, d.distance); err != nil {
86+ return err
87+ }
88+ }
89+
90+ e.logger.Info("follow distances computed", "pairs", len(distances))
3491 return tx.Commit()
3592 }
3693
37-func (e *Engine) ComputeFollowDistancesIncremental(ctx context.Context) error {
38- var maxFollowed string
39- err := e.db.QueryRowContext(ctx, `
40- SELECT COALESCE(MAX(followed_at), '1970-01-01') FROM main.follows
41- `).Scan(&maxFollowed)
94+// ComputeFollowDistances incrementally recomputes follow distances for users
95+// whose follows changed since the last run, as tracked by the follows_dirty column.
96+func (e *Engine) ComputeFollowDistances(ctx context.Context) error {
97+ rows, err := e.db.QueryContext(ctx, `SELECT did FROM main.users WHERE follows_dirty = 1`)
4298 if err != nil {
4399 return err
44100 }
45101
46- var lastComputed string
47- err = e.db.QueryRowContext(ctx, `
48- SELECT COALESCE(MAX(computed_at), '1970-01-01') FROM recs.user_similarity
49- `).Scan(&lastComputed)
102+ var dirtyUsers []string
103+ for rows.Next() {
104+ var did string
105+ if err := rows.Scan(&did); err != nil {
106+ rows.Close()
107+ return err
108+ }
109+ dirtyUsers = append(dirtyUsers, did)
110+ }
111+ rows.Close()
112+
113+ if len(dirtyUsers) == 0 {
114+ return nil
115+ }
116+
117+ distances, err := e.ComputeFollowDistancesData(ctx, dirtyUsers)
50118 if err != nil {
51119 return err
52120 }
53121
54- if maxFollowed <= lastComputed {
55- return nil
122+ tx, err := e.db.BeginTx(ctx, nil)
123+ if err != nil {
124+ return err
125+ }
126+ defer func() { _ = tx.Rollback() }()
127+
128+ ph := make([]string, len(dirtyUsers))
129+ args := make([]any, len(dirtyUsers))
130+ for i, did := range dirtyUsers {
131+ ph[i] = "?"
132+ args[i] = did
133+ }
134+ if _, err := tx.ExecContext(ctx,
135+ fmt.Sprintf("DELETE FROM recs.follow_distances WHERE user_a IN (%s)", joinPh(ph)),
136+ args...,
137+ ); err != nil {
138+ return err
56139 }
57140
58- return e.ComputeFollowDistances(ctx)
141+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO recs.follow_distances (user_a, user_b, distance) VALUES (?, ?, ?)`)
142+ if err != nil {
143+ return err
144+ }
145+ defer stmt.Close()
146+
147+ for _, d := range distances {
148+ if _, err := stmt.ExecContext(ctx, d.userA, d.userB, d.distance); err != nil {
149+ return err
150+ }
151+ }
152+
153+ if _, err := tx.ExecContext(ctx,
154+ fmt.Sprintf("UPDATE main.users SET follows_dirty = 0 WHERE did IN (%s)", joinPh(ph)),
155+ args...,
156+ ); err != nil {
157+ return err
158+ }
159+
160+ e.logger.Info("follow distances computed", "users", len(dirtyUsers), "pairs", len(distances))
161+ return tx.Commit()
59162 }
@@ -2,9 +2,69 @@ package cluster
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 )6 )
6 7
7-func (e *Engine) ComputeFollowDistances(ctx context.Context) error {8+const maxFollowDepth = 3
9+
10+type followDistance struct {
11+ userA string
12+ userB string
13+ distance int
14+}
15+
16+func (e *Engine) ComputeFollowDistancesData(ctx context.Context, sources []string) ([]followDistance, error) {
17+ if len(sources) == 0 {
18+ return nil, nil
19+ }
20+
21+ rows, err := e.db.QueryContext(ctx, `SELECT user_did, target_did FROM main.follows WHERE user_did != target_did`)
22+ if err != nil {
23+ return nil, err
24+ }
25+ defer rows.Close()
26+
27+ adj := make(map[string][]string)
28+ for rows.Next() {
29+ var src, dst string
30+ if err := rows.Scan(&src, &dst); err != nil {
31+ return nil, err
32+ }
33+ adj[src] = append(adj[src], dst)
34+ }
35+ if err := rows.Err(); err != nil {
36+ return nil, err
37+ }
38+
39+ var result []followDistance
40+ for _, src := range sources {
41+ dist := map[string]int{src: 0}
42+ queue := []string{src}
43+ for len(queue) > 0 {
44+ cur := queue[0]
45+ queue = queue[1:]
46+ d := dist[cur]
47+ if d >= maxFollowDepth {
48+ continue
49+ }
50+ for _, next := range adj[cur] {
51+ if _, ok := dist[next]; !ok {
52+ dist[next] = d + 1
53+ queue = append(queue, next)
54+ }
55+ }
56+ }
57+ for other, d := range dist {
58+ if d > 0 {
59+ result = append(result, followDistance{userA: src, userB: other, distance: d})
60+ }
61+ }
62+ }
63+
64+ return result, nil
65+}
66+
67+func (e *Engine) WriteFollowDistances(ctx context.Context, distances []followDistance) error {
8 tx, err := e.db.BeginTx(ctx, nil)68 tx, err := e.db.BeginTx(ctx, nil)
9 if err != nil {69 if err != nil {
10 return err70 return err
@@ -15,45 +75,88 @@ func (e *Engine) ComputeFollowDistances(ctx context.Context) error {
15 return err75 return err
16 }76 }
17 77
18- _, err = tx.ExecContext(ctx, `78+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO recs.follow_distances (user_a, user_b, distance) VALUES (?, ?, ?)`)
19- INSERT INTO recs.follow_distances (user_a, user_b, distance)
20- SELECT user_a, user_b, MIN(distance) FROM (
21- SELECT user_did AS user_a, target_did AS user_b, 1 AS distance FROM main.follows WHERE user_did != target_did
22- UNION ALL
23- SELECT f1.user_did, f2.target_did, 2
24- FROM main.follows f1
25- JOIN main.follows f2 ON f1.target_did = f2.user_did
26- WHERE f1.user_did != f2.target_did
27- ) GROUP BY user_a, user_b
28- `)
29 if err != nil {79 if err != nil {
30 return err80 return err
31 }81 }
82+ defer stmt.Close()
32 83
33- e.logger.Info("follow distances computed")84+ for _, d := range distances {
85+ if _, err := stmt.ExecContext(ctx, d.userA, d.userB, d.distance); err != nil {
86+ return err
87+ }
88+ }
89+
90+ e.logger.Info("follow distances computed", "pairs", len(distances))
34 return tx.Commit()91 return tx.Commit()
35 }92 }
36 93
37-func (e *Engine) ComputeFollowDistancesIncremental(ctx context.Context) error {94+// ComputeFollowDistances incrementally recomputes follow distances for users
38- var maxFollowed string95+// whose follows changed since the last run, as tracked by the follows_dirty column.
39- err := e.db.QueryRowContext(ctx, `96+func (e *Engine) ComputeFollowDistances(ctx context.Context) error {
40- SELECT COALESCE(MAX(followed_at), '1970-01-01') FROM main.follows97+ rows, err := e.db.QueryContext(ctx, `SELECT did FROM main.users WHERE follows_dirty = 1`)
41- `).Scan(&maxFollowed)
42 if err != nil {98 if err != nil {
43 return err99 return err
44 }100 }
45 101
46- var lastComputed string102+ var dirtyUsers []string
47- err = e.db.QueryRowContext(ctx, `103+ for rows.Next() {
48- SELECT COALESCE(MAX(computed_at), '1970-01-01') FROM recs.user_similarity104+ var did string
49- `).Scan(&lastComputed)105+ if err := rows.Scan(&did); err != nil {
106+ rows.Close()
107+ return err
108+ }
109+ dirtyUsers = append(dirtyUsers, did)
110+ }
111+ rows.Close()
112+
113+ if len(dirtyUsers) == 0 {
114+ return nil
115+ }
116+
117+ distances, err := e.ComputeFollowDistancesData(ctx, dirtyUsers)
50 if err != nil {118 if err != nil {
51 return err119 return err
52 }120 }
53 121
54- if maxFollowed <= lastComputed {122+ tx, err := e.db.BeginTx(ctx, nil)
55- return nil123+ if err != nil {
124+ return err
125+ }
126+ defer func() { _ = tx.Rollback() }()
127+
128+ ph := make([]string, len(dirtyUsers))
129+ args := make([]any, len(dirtyUsers))
130+ for i, did := range dirtyUsers {
131+ ph[i] = "?"
132+ args[i] = did
133+ }
134+ if _, err := tx.ExecContext(ctx,
135+ fmt.Sprintf("DELETE FROM recs.follow_distances WHERE user_a IN (%s)", joinPh(ph)),
136+ args...,
137+ ); err != nil {
138+ return err
56 }139 }
57 140
58- return e.ComputeFollowDistances(ctx)141+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO recs.follow_distances (user_a, user_b, distance) VALUES (?, ?, ?)`)
142+ if err != nil {
143+ return err
144+ }
145+ defer stmt.Close()
146+
147+ for _, d := range distances {
148+ if _, err := stmt.ExecContext(ctx, d.userA, d.userB, d.distance); err != nil {
149+ return err
150+ }
151+ }
152+
153+ if _, err := tx.ExecContext(ctx,
154+ fmt.Sprintf("UPDATE main.users SET follows_dirty = 0 WHERE did IN (%s)", joinPh(ph)),
155+ args...,
156+ ); err != nil {
157+ return err
158+ }
159+
160+ e.logger.Info("follow distances computed", "users", len(dirtyUsers), "pairs", len(distances))
161+ return tx.Commit()
59 }162 }
modified internal/cluster/weights.go +11 -5
@@ -11,20 +11,22 @@ const (
1111 minActionsTune = 5
1212 )
1313
14+// RewardSignal increases the weight of the given signal for a user. Only takes
15+// effect after minActionsTune positive actions. Signal must be one of: "sub",
16+// "like", "tag", "social", "pop", "category", "content".
1417 func (e *Engine) RewardSignal(ctx context.Context, userDID string, signal string) {
15- return
1618 e.adjustWeight(ctx, userDID, signal, 1.0)
1719 }
1820
21+// PenalizeSignal decreases the weight of the given signal for a user.
1922 func (e *Engine) PenalizeSignal(ctx context.Context, userDID string, signal string) {
20- return
2123 e.adjustWeight(ctx, userDID, signal, -1.0)
2224 }
2325
2426 func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string, delta float64) {
2527 var actedCount int
2628 _ = e.db.QueryRowContext(ctx, `
27- SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = ? AND acted = 1
29+ SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = ? AND acted = 1
2830 `, userDID).Scan(&actedCount)
2931 if actedCount < minActionsTune {
3032 return
@@ -35,8 +37,8 @@ func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string
3537
3638 if exists == 0 {
3739 _, _ = e.db.ExecContext(ctx, `
38- INSERT INTO recs.user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category)
39- VALUES (?, 1.0, 0.5, 0.3, 0.7, 0.2, 0.4)
40+ INSERT INTO recs.user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category, w_content)
41+ VALUES (?, 1.0, 0.5, 0.3, 0.7, 0.2, 0.4, 0.4)
4042 `, userDID)
4143 }
4244
@@ -68,11 +70,14 @@ func signalToColumn(signal string) string {
6870 return "w_pop"
6971 case "category":
7072 return "w_category"
73+ case "content":
74+ return "w_content"
7175 default:
7276 return ""
7377 }
7478 }
7579
80+// GetDominantSignal returns the signal name with the highest weight.
7681 func (e *Engine) GetDominantSignal(w SignalWeights) string {
7782 signals := map[string]float64{
7883 "sub": w.WSub,
@@ -81,6 +86,7 @@ func (e *Engine) GetDominantSignal(w SignalWeights) string {
8186 "social": w.WSocial,
8287 "pop": w.WPop,
8388 "category": w.WCategory,
89+ "content": w.WContent,
8490 }
8591
8692 var best string
@@ -11,20 +11,22 @@ const (
11 minActionsTune = 511 minActionsTune = 5
12 )12 )
13 13
14+// RewardSignal increases the weight of the given signal for a user. Only takes
15+// effect after minActionsTune positive actions. Signal must be one of: "sub",
16+// "like", "tag", "social", "pop", "category", "content".
14 func (e *Engine) RewardSignal(ctx context.Context, userDID string, signal string) {17 func (e *Engine) RewardSignal(ctx context.Context, userDID string, signal string) {
15- return
16 e.adjustWeight(ctx, userDID, signal, 1.0)18 e.adjustWeight(ctx, userDID, signal, 1.0)
17 }19 }
18 20
21+// PenalizeSignal decreases the weight of the given signal for a user.
19 func (e *Engine) PenalizeSignal(ctx context.Context, userDID string, signal string) {22 func (e *Engine) PenalizeSignal(ctx context.Context, userDID string, signal string) {
20- return
21 e.adjustWeight(ctx, userDID, signal, -1.0)23 e.adjustWeight(ctx, userDID, signal, -1.0)
22 }24 }
23 25
24 func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string, delta float64) {26 func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string, delta float64) {
25 var actedCount int27 var actedCount int
26 _ = e.db.QueryRowContext(ctx, `28 _ = e.db.QueryRowContext(ctx, `
27- SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = ? AND acted = 129+ SELECT COUNT(*) FROM main.recommendation_impressions WHERE user_did = ? AND acted = 1
28 `, userDID).Scan(&actedCount)30 `, userDID).Scan(&actedCount)
29 if actedCount < minActionsTune {31 if actedCount < minActionsTune {
30 return32 return
@@ -35,8 +37,8 @@ func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string
35 37
36 if exists == 0 {38 if exists == 0 {
37 _, _ = e.db.ExecContext(ctx, `39 _, _ = e.db.ExecContext(ctx, `
38- INSERT INTO recs.user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category)40+ INSERT INTO recs.user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category, w_content)
39- VALUES (?, 1.0, 0.5, 0.3, 0.7, 0.2, 0.4)41+ VALUES (?, 1.0, 0.5, 0.3, 0.7, 0.2, 0.4, 0.4)
40 `, userDID)42 `, userDID)
41 }43 }
42 44
@@ -68,11 +70,14 @@ func signalToColumn(signal string) string {
68 return "w_pop"70 return "w_pop"
69 case "category":71 case "category":
70 return "w_category"72 return "w_category"
73+ case "content":
74+ return "w_content"
71 default:75 default:
72 return ""76 return ""
73 }77 }
74 }78 }
75 79
80+// GetDominantSignal returns the signal name with the highest weight.
76 func (e *Engine) GetDominantSignal(w SignalWeights) string {81 func (e *Engine) GetDominantSignal(w SignalWeights) string {
77 signals := map[string]float64{82 signals := map[string]float64{
78 "sub": w.WSub,83 "sub": w.WSub,
@@ -81,6 +86,7 @@ func (e *Engine) GetDominantSignal(w SignalWeights) string {
81 "social": w.WSocial,86 "social": w.WSocial,
82 "pop": w.WPop,87 "pop": w.WPop,
83 "category": w.WCategory,88 "category": w.WCategory,
89+ "content": w.WContent,
84 }90 }
85 91
86 var best string92 var best string
modified internal/db/db.go +49 -25
@@ -1,6 +1,7 @@
11 package db
22
33 import (
4+ "context"
45 "database/sql"
56 "fmt"
67 "math"
@@ -126,6 +127,21 @@ func (d *Databases) Close() error {
126127 return nil
127128 }
128129
130+func (d *Databases) InitVecTables(dimension int) error {
131+ if dimension <= 0 {
132+ return nil
133+ }
134+ for _, stmt := range []string{
135+ fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS recs.feed_embeddings USING vec0(feed_url TEXT PRIMARY KEY, embedding float[%d])`, dimension),
136+ fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS recs.article_embeddings USING vec0(article_id INTEGER PRIMARY KEY, embedding float[%d])`, dimension),
137+ } {
138+ if _, err := d.db.ExecContext(context.Background(), stmt); err != nil {
139+ return fmt.Errorf("create vec0 table: %w", err)
140+ }
141+ }
142+ return nil
143+}
144+
129145 func (d *Databases) DB() *sql.DB {
130146 return d.db.DB
131147 }
@@ -161,7 +177,8 @@ var usersSchema = []string{
161177 `CREATE TABLE IF NOT EXISTS users (
162178 did TEXT PRIMARY KEY,
163179 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
164- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
180+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
181+ follows_dirty BOOLEAN NOT NULL DEFAULT 1
165182 )`,
166183
167184 `CREATE TABLE IF NOT EXISTS follows (
@@ -189,6 +206,30 @@ var usersSchema = []string{
189206 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
190207 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
191208 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
209+
210+ `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
211+ user_did TEXT NOT NULL,
212+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
213+ target_id TEXT NOT NULL,
214+ reason TEXT,
215+ dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
216+ PRIMARY KEY (user_did, target_type, target_id)
217+ )`,
218+
219+ `CREATE TABLE IF NOT EXISTS recommendation_impressions (
220+ user_did TEXT NOT NULL,
221+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
222+ target_id TEXT NOT NULL,
223+ first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
224+ last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
225+ shown_count INTEGER NOT NULL DEFAULT 1,
226+ acted BOOLEAN NOT NULL DEFAULT 0,
227+ PRIMARY KEY (user_did, target_type, target_id)
228+ )`,
229+
230+ `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
231+ `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
232+ `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
192233 }
193234
194235 var articlesSchema = []string{
@@ -318,30 +359,10 @@ var recsSchema = []string{
318359 CHECK(user_a < user_b)
319360 )`,
320361
321- `CREATE TABLE IF NOT EXISTS recs.dismissed_recommendations (
322- user_did TEXT NOT NULL,
323- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
324- target_id TEXT NOT NULL,
325- reason TEXT,
326- dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
327- PRIMARY KEY (user_did, target_type, target_id)
328- )`,
329-
330- `CREATE TABLE IF NOT EXISTS recs.recommendation_impressions (
331- user_did TEXT NOT NULL,
332- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
333- target_id TEXT NOT NULL,
334- first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
335- last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
336- shown_count INTEGER NOT NULL DEFAULT 1,
337- acted BOOLEAN NOT NULL DEFAULT 0,
338- PRIMARY KEY (user_did, target_type, target_id)
339- )`,
340-
341362 `CREATE TABLE IF NOT EXISTS recs.follow_distances (
342363 user_a TEXT NOT NULL,
343364 user_b TEXT NOT NULL,
344- distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
365+ distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)),
345366 PRIMARY KEY (user_a, user_b)
346367 )`,
347368
@@ -353,6 +374,7 @@ var recsSchema = []string{
353374 w_social REAL NOT NULL DEFAULT 0.7,
354375 w_pop REAL NOT NULL DEFAULT 0.2,
355376 w_category REAL NOT NULL DEFAULT 0.4,
377+ w_content REAL NOT NULL DEFAULT 0.4,
356378 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
357379 )`,
358380
@@ -364,9 +386,11 @@ var recsSchema = []string{
364386 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
365387 )`,
366388
367- `CREATE INDEX IF NOT EXISTS recs.idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
368- `CREATE INDEX IF NOT EXISTS recs.idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
369- `CREATE INDEX IF NOT EXISTS recs.idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
389+ `CREATE TABLE IF NOT EXISTS recs.feed_embedding_meta (
390+ feed_url TEXT PRIMARY KEY,
391+ source_text TEXT NOT NULL DEFAULT ''
392+ )`,
393+
370394 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_b ON follow_distances(user_b)`,
371395 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
372396 `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,
@@ -1,6 +1,7 @@
1 package db1 package db
2 2
3 import (3 import (
4+ "context"
4 "database/sql"5 "database/sql"
5 "fmt"6 "fmt"
6 "math"7 "math"
@@ -126,6 +127,21 @@ func (d *Databases) Close() error {
126 return nil127 return nil
127 }128 }
128 129
130+func (d *Databases) InitVecTables(dimension int) error {
131+ if dimension <= 0 {
132+ return nil
133+ }
134+ for _, stmt := range []string{
135+ fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS recs.feed_embeddings USING vec0(feed_url TEXT PRIMARY KEY, embedding float[%d])`, dimension),
136+ fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS recs.article_embeddings USING vec0(article_id INTEGER PRIMARY KEY, embedding float[%d])`, dimension),
137+ } {
138+ if _, err := d.db.ExecContext(context.Background(), stmt); err != nil {
139+ return fmt.Errorf("create vec0 table: %w", err)
140+ }
141+ }
142+ return nil
143+}
144+
129 func (d *Databases) DB() *sql.DB {145 func (d *Databases) DB() *sql.DB {
130 return d.db.DB146 return d.db.DB
131 }147 }
@@ -161,7 +177,8 @@ var usersSchema = []string{
161 `CREATE TABLE IF NOT EXISTS users (177 `CREATE TABLE IF NOT EXISTS users (
162 did TEXT PRIMARY KEY,178 did TEXT PRIMARY KEY,
163 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,179 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
164- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP180+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
181+ follows_dirty BOOLEAN NOT NULL DEFAULT 1
165 )`,182 )`,
166 183
167 `CREATE TABLE IF NOT EXISTS follows (184 `CREATE TABLE IF NOT EXISTS follows (
@@ -189,6 +206,30 @@ var usersSchema = []string{
189 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,206 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
190 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,207 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
191 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,208 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
209+
210+ `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
211+ user_did TEXT NOT NULL,
212+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
213+ target_id TEXT NOT NULL,
214+ reason TEXT,
215+ dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
216+ PRIMARY KEY (user_did, target_type, target_id)
217+ )`,
218+
219+ `CREATE TABLE IF NOT EXISTS recommendation_impressions (
220+ user_did TEXT NOT NULL,
221+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
222+ target_id TEXT NOT NULL,
223+ first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
224+ last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
225+ shown_count INTEGER NOT NULL DEFAULT 1,
226+ acted BOOLEAN NOT NULL DEFAULT 0,
227+ PRIMARY KEY (user_did, target_type, target_id)
228+ )`,
229+
230+ `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
231+ `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
232+ `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
192 }233 }
193 234
194 var articlesSchema = []string{235 var articlesSchema = []string{
@@ -318,30 +359,10 @@ var recsSchema = []string{
318 CHECK(user_a < user_b)359 CHECK(user_a < user_b)
319 )`,360 )`,
320 361
321- `CREATE TABLE IF NOT EXISTS recs.dismissed_recommendations (
322- user_did TEXT NOT NULL,
323- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
324- target_id TEXT NOT NULL,
325- reason TEXT,
326- dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
327- PRIMARY KEY (user_did, target_type, target_id)
328- )`,
329-
330- `CREATE TABLE IF NOT EXISTS recs.recommendation_impressions (
331- user_did TEXT NOT NULL,
332- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
333- target_id TEXT NOT NULL,
334- first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
335- last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
336- shown_count INTEGER NOT NULL DEFAULT 1,
337- acted BOOLEAN NOT NULL DEFAULT 0,
338- PRIMARY KEY (user_did, target_type, target_id)
339- )`,
340-
341 `CREATE TABLE IF NOT EXISTS recs.follow_distances (362 `CREATE TABLE IF NOT EXISTS recs.follow_distances (
342 user_a TEXT NOT NULL,363 user_a TEXT NOT NULL,
343 user_b TEXT NOT NULL,364 user_b TEXT NOT NULL,
344- distance INTEGER NOT NULL CHECK(distance IN (1, 2)),365+ distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)),
345 PRIMARY KEY (user_a, user_b)366 PRIMARY KEY (user_a, user_b)
346 )`,367 )`,
347 368
@@ -353,6 +374,7 @@ var recsSchema = []string{
353 w_social REAL NOT NULL DEFAULT 0.7,374 w_social REAL NOT NULL DEFAULT 0.7,
354 w_pop REAL NOT NULL DEFAULT 0.2,375 w_pop REAL NOT NULL DEFAULT 0.2,
355 w_category REAL NOT NULL DEFAULT 0.4,376 w_category REAL NOT NULL DEFAULT 0.4,
377+ w_content REAL NOT NULL DEFAULT 0.4,
356 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP378 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
357 )`,379 )`,
358 380
@@ -364,9 +386,11 @@ var recsSchema = []string{
364 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP386 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
365 )`,387 )`,
366 388
367- `CREATE INDEX IF NOT EXISTS recs.idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,389+ `CREATE TABLE IF NOT EXISTS recs.feed_embedding_meta (
368- `CREATE INDEX IF NOT EXISTS recs.idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,390+ feed_url TEXT PRIMARY KEY,
369- `CREATE INDEX IF NOT EXISTS recs.idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,391+ source_text TEXT NOT NULL DEFAULT ''
392+ )`,
393+
370 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_b ON follow_distances(user_b)`,394 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_b ON follow_distances(user_b)`,
371 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,395 `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
372 `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,396 `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,
modified internal/db/follow.go +22 -1
@@ -22,16 +22,28 @@ func (s *UserStore) UpsertFollow(ctx context.Context, userDID, targetDID, uri, c
2222 uri = excluded.uri,
2323 cid = excluded.cid
2424 `, userDID, targetDID, nilIfEmpty(uri), nilIfEmpty(cid))
25+ if err != nil {
26+ return err
27+ }
28+ _, err = s.db.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did = ?`, userDID)
2529 return err
2630 }
2731
2832 func (s *UserStore) DeleteFollow(ctx context.Context, userDID, targetDID string) error {
2933 _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID)
34+ if err != nil {
35+ return err
36+ }
37+ _, err = s.db.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did = ?`, userDID)
3038 return err
3139 }
3240
3341 func (s *UserStore) DeleteFollowByURI(ctx context.Context, uri string) error {
34- _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)
42+ _, err := s.db.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did IN (SELECT user_did FROM follows WHERE uri = ?)`, uri)
43+ if err != nil {
44+ return err
45+ }
46+ _, err = s.db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)
3547 return err
3648 }
3749
@@ -139,11 +151,13 @@ func (s *UserStore) SyncFollows(ctx context.Context, userDID string, activeFollo
139151 }
140152 rows.Close()
141153
154+ var changed bool
142155 for targetDID := range existing {
143156 if _, ok := activeFollows[targetDID]; !ok {
144157 if _, err := tx.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID); err != nil {
145158 return err
146159 }
160+ changed = true
147161 }
148162 }
149163
@@ -163,6 +177,13 @@ func (s *UserStore) SyncFollows(ctx context.Context, userDID string, activeFollo
163177 if err != nil {
164178 return err
165179 }
180+ changed = true
181+ }
182+ }
183+
184+ if changed {
185+ if _, err := tx.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did = ?`, userDID); err != nil {
186+ return err
166187 }
167188 }
168189
@@ -22,16 +22,28 @@ func (s *UserStore) UpsertFollow(ctx context.Context, userDID, targetDID, uri, c
22 uri = excluded.uri,22 uri = excluded.uri,
23 cid = excluded.cid23 cid = excluded.cid
24 `, userDID, targetDID, nilIfEmpty(uri), nilIfEmpty(cid))24 `, userDID, targetDID, nilIfEmpty(uri), nilIfEmpty(cid))
25+ if err != nil {
26+ return err
27+ }
28+ _, err = s.db.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did = ?`, userDID)
25 return err29 return err
26 }30 }
27 31
28 func (s *UserStore) DeleteFollow(ctx context.Context, userDID, targetDID string) error {32 func (s *UserStore) DeleteFollow(ctx context.Context, userDID, targetDID string) error {
29 _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID)33 _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID)
34+ if err != nil {
35+ return err
36+ }
37+ _, err = s.db.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did = ?`, userDID)
30 return err38 return err
31 }39 }
32 40
33 func (s *UserStore) DeleteFollowByURI(ctx context.Context, uri string) error {41 func (s *UserStore) DeleteFollowByURI(ctx context.Context, uri string) error {
34- _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)42+ _, err := s.db.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did IN (SELECT user_did FROM follows WHERE uri = ?)`, uri)
43+ if err != nil {
44+ return err
45+ }
46+ _, err = s.db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)
35 return err47 return err
36 }48 }
37 49
@@ -139,11 +151,13 @@ func (s *UserStore) SyncFollows(ctx context.Context, userDID string, activeFollo
139 }151 }
140 rows.Close()152 rows.Close()
141 153
154+ var changed bool
142 for targetDID := range existing {155 for targetDID := range existing {
143 if _, ok := activeFollows[targetDID]; !ok {156 if _, ok := activeFollows[targetDID]; !ok {
144 if _, err := tx.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID); err != nil {157 if _, err := tx.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID); err != nil {
145 return err158 return err
146 }159 }
160+ changed = true
147 }161 }
148 }162 }
149 163
@@ -163,6 +177,13 @@ func (s *UserStore) SyncFollows(ctx context.Context, userDID string, activeFollo
163 if err != nil {177 if err != nil {
164 return err178 return err
165 }179 }
180+ changed = true
181+ }
182+ }
183+
184+ if changed {
185+ if _, err := tx.ExecContext(ctx, `UPDATE users SET follows_dirty = 1 WHERE did = ?`, userDID); err != nil {
186+ return err
166 }187 }
167 }188 }
168 189
added internal/db/include/sqlite3.h +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+#ifndef SQLITE3_H_BRIDGE
2+#define SQLITE3_H_BRIDGE
3+#include "sqlite3-binding.h"
4+#endif
new file mode 100644
@@ -0,0 +1,4 @@
1+#ifndef SQLITE3_H_BRIDGE
2+#define SQLITE3_H_BRIDGE
3+#include "sqlite3-binding.h"
4+#endif
modified internal/db/user.go +11 -10
@@ -6,12 +6,13 @@ import (
66 )
77
88 type User struct {
9- DID string
10- Handle string
11- DisplayName string
12- AvatarURL string
13- IndexedAt sql.NullTime
14- UpdatedAt sql.NullTime
9+ DID string
10+ Handle string
11+ DisplayName string
12+ AvatarURL string
13+ IndexedAt sql.NullTime
14+ UpdatedAt sql.NullTime
15+ FollowsDirty bool
1516 }
1617
1718 type UserStore struct {
@@ -60,8 +61,8 @@ func (s *UserStore) CreateUser(ctx context.Context, did string) (*User, error) {
6061 func (s *UserStore) GetUser(ctx context.Context, did string) (*User, error) {
6162 u := &User{}
6263 err := s.db.QueryRowContext(ctx, `
63- SELECT * FROM users WHERE did = ?
64- `, did).Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt)
64+ SELECT did, indexed_at, updated_at, follows_dirty FROM users WHERE did = ?
65+ `, did).Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt, &u.FollowsDirty)
6566 if err != nil {
6667 return nil, err
6768 }
@@ -88,7 +89,7 @@ func (s *UserStore) ListUserDIDs(ctx context.Context) (map[string]bool, error) {
8889
8990 func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) {
9091 rows, err := s.db.QueryContext(ctx, `
91- SELECT did, indexed_at, updated_at
92+ SELECT did, indexed_at, updated_at, follows_dirty
9293 FROM users ORDER BY updated_at DESC
9394 `)
9495 if err != nil {
@@ -99,7 +100,7 @@ func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) {
99100 var users []*User
100101 for rows.Next() {
101102 u := &User{}
102- if err := rows.Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt); err != nil {
103+ if err := rows.Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt, &u.FollowsDirty); err != nil {
103104 return nil, err
104105 }
105106 users = append(users, u)
@@ -6,12 +6,13 @@ import (
6 )6 )
7 7
8 type User struct {8 type User struct {
9- DID string9+ DID string
10- Handle string10+ Handle string
11- DisplayName string11+ DisplayName string
12- AvatarURL string12+ AvatarURL string
13- IndexedAt sql.NullTime13+ IndexedAt sql.NullTime
14- UpdatedAt sql.NullTime14+ UpdatedAt sql.NullTime
15+ FollowsDirty bool
15 }16 }
16 17
17 type UserStore struct {18 type UserStore struct {
@@ -60,8 +61,8 @@ func (s *UserStore) CreateUser(ctx context.Context, did string) (*User, error) {
60 func (s *UserStore) GetUser(ctx context.Context, did string) (*User, error) {61 func (s *UserStore) GetUser(ctx context.Context, did string) (*User, error) {
61 u := &User{}62 u := &User{}
62 err := s.db.QueryRowContext(ctx, `63 err := s.db.QueryRowContext(ctx, `
63- SELECT * FROM users WHERE did = ?64+ SELECT did, indexed_at, updated_at, follows_dirty FROM users WHERE did = ?
64- `, did).Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt)65+ `, did).Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt, &u.FollowsDirty)
65 if err != nil {66 if err != nil {
66 return nil, err67 return nil, err
67 }68 }
@@ -88,7 +89,7 @@ func (s *UserStore) ListUserDIDs(ctx context.Context) (map[string]bool, error) {
88 89
89 func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) {90 func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) {
90 rows, err := s.db.QueryContext(ctx, `91 rows, err := s.db.QueryContext(ctx, `
91- SELECT did, indexed_at, updated_at92+ SELECT did, indexed_at, updated_at, follows_dirty
92 FROM users ORDER BY updated_at DESC93 FROM users ORDER BY updated_at DESC
93 `)94 `)
94 if err != nil {95 if err != nil {
@@ -99,7 +100,7 @@ func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) {
99 var users []*User100 var users []*User
100 for rows.Next() {101 for rows.Next() {
101 u := &User{}102 u := &User{}
102- if err := rows.Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt); err != nil {103+ if err := rows.Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt, &u.FollowsDirty); err != nil {
103 return nil, err104 return nil, err
104 }105 }
105 users = append(users, u)106 users = append(users, u)
modified main.go +21 -1
@@ -17,6 +17,8 @@ import (
1717 "pkg.rbrt.fr/glean/internal/db"
1818 "pkg.rbrt.fr/glean/internal/feed"
1919 "pkg.rbrt.fr/glean/internal/server"
20+
21+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
2022 )
2123
2224 func main() {
@@ -40,6 +42,7 @@ func main() {
4042
4143 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
4244
45+ vec.Auto()
4346 dbs, err := db.OpenAll(*dbPath)
4447 if err != nil {
4548 logger.Error("failed to open databases", "error", err)
@@ -53,7 +56,24 @@ func main() {
5356 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles)
5457 scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute)
5558
56- engine := cluster.NewEngine(dbs.DB(), logger)
59+ var embedder cluster.Embedder
60+ if embedURL := envOr("GLEAN_EMBED_BASE_URL", ""); embedURL != "" {
61+ embedder = cluster.NewOpenAIEmbedder(cluster.OpenAIEmbedderConfig{
62+ BaseURL: embedURL,
63+ APIKey: envOr("GLEAN_EMBED_API_KEY", ""),
64+ Model: envOr("GLEAN_EMBED_MODEL", "text-embedding-3-small"),
65+ Dimension: envInt("GLEAN_EMBED_DIMENSION", 1536),
66+ })
67+ }
68+
69+ if embedder != nil {
70+ if err := dbs.InitVecTables(embedder.Dimension()); err != nil {
71+ logger.Error("failed to init vec tables", "error", err)
72+ os.Exit(1)
73+ }
74+ }
75+
76+ engine := cluster.NewEngine(dbs.DB(), embedder, logger)
5777
5878 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger, []byte(sessionKey))
5979
@@ -17,6 +17,8 @@ import (
17 "pkg.rbrt.fr/glean/internal/db"17 "pkg.rbrt.fr/glean/internal/db"
18 "pkg.rbrt.fr/glean/internal/feed"18 "pkg.rbrt.fr/glean/internal/feed"
19 "pkg.rbrt.fr/glean/internal/server"19 "pkg.rbrt.fr/glean/internal/server"
20+
21+ vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
20 )22 )
21 23
22 func main() {24 func main() {
@@ -40,6 +42,7 @@ func main() {
40 42
41 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))43 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
42 44
45+ vec.Auto()
43 dbs, err := db.OpenAll(*dbPath)46 dbs, err := db.OpenAll(*dbPath)
44 if err != nil {47 if err != nil {
45 logger.Error("failed to open databases", "error", err)48 logger.Error("failed to open databases", "error", err)
@@ -53,7 +56,24 @@ func main() {
53 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles)56 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles)
54 scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute)57 scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute)
55 58
56- engine := cluster.NewEngine(dbs.DB(), logger)59+ var embedder cluster.Embedder
60+ if embedURL := envOr("GLEAN_EMBED_BASE_URL", ""); embedURL != "" {
61+ embedder = cluster.NewOpenAIEmbedder(cluster.OpenAIEmbedderConfig{
62+ BaseURL: embedURL,
63+ APIKey: envOr("GLEAN_EMBED_API_KEY", ""),
64+ Model: envOr("GLEAN_EMBED_MODEL", "text-embedding-3-small"),
65+ Dimension: envInt("GLEAN_EMBED_DIMENSION", 1536),
66+ })
67+ }
68+
69+ if embedder != nil {
70+ if err := dbs.InitVecTables(embedder.Dimension()); err != nil {
71+ logger.Error("failed to init vec tables", "error", err)
72+ os.Exit(1)
73+ }
74+ }
75+
76+ engine := cluster.NewEngine(dbs.DB(), embedder, logger)
57 77
58 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger, []byte(sessionKey))78 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger, []byte(sessionKey))
59 79
modified readme.md +18 -0
@@ -16,6 +16,20 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away
1616 - OPML import and export
1717 - Sign in with Bluesky / Atmosphere account — no new account needed
1818
19+## How recommendations work
20+
21+Glean looks at what you and other users subscribe to, read, and like to suggest feeds and people you might enjoy.
22+
23+**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+
25+**People suggestions** are readers whose subscriptions overlap with yours. The more feeds you share, the higher they rank. You also see whether you have any Bluesky follows in common.
26+
27+**Dismissals** keep things tidy. If you dismiss a recommendation, it won't come back. If a suggestion sits ignored for more than 5 days, it's automatically removed so newer recommendations can take its place.
28+
29+**Cold start.** If you're new and have fewer than five subscriptions, Glean shows feeds from people you follow on Bluesky alongside popular feeds from the community, so there's something to explore right away.
30+
31+The system improves over time: as you subscribe to feeds and like articles, Glean learns which signals matter most to you and adjusts accordingly.
32+
1933 ## Self-hosting
2034
2135 ### Docker
@@ -51,6 +65,10 @@ Then open `http://localhost:8080`.
5165 | `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |
5266 | `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
5367 | `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |
68+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
69+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
70+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
71+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
5472
5573 For production:
5674
@@ -16,6 +16,20 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away
16 - OPML import and export16 - OPML import and export
17 - Sign in with Bluesky / Atmosphere account — no new account needed17 - Sign in with Bluesky / Atmosphere account — no new account needed
18 18
19+## How recommendations work
20+
21+Glean looks at what you and other users subscribe to, read, and like to suggest feeds and people you might enjoy.
22+
23+**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+
25+**People suggestions** are readers whose subscriptions overlap with yours. The more feeds you share, the higher they rank. You also see whether you have any Bluesky follows in common.
26+
27+**Dismissals** keep things tidy. If you dismiss a recommendation, it won't come back. If a suggestion sits ignored for more than 5 days, it's automatically removed so newer recommendations can take its place.
28+
29+**Cold start.** If you're new and have fewer than five subscriptions, Glean shows feeds from people you follow on Bluesky alongside popular feeds from the community, so there's something to explore right away.
30+
31+The system improves over time: as you subscribe to feeds and like articles, Glean learns which signals matter most to you and adjusts accordingly.
32+
19 ## Self-hosting33 ## Self-hosting
20 34
21 ### Docker35 ### Docker
@@ -51,6 +65,10 @@ Then open `http://localhost:8080`.
51 | `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |65 | `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution |
52 | `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |66 | `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
53 | `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |67 | `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |
68+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
69+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
70+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
71+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
54 72
55 For production:73 For production:
56 74