···143143144144The mapping from Skyreader subscription to Glean subscription:
145145146146-| Skyreader field | Glean field | Notes |
147147-| --------------- | ------------- | ------------------------------------ |
148148-| `feedUrl` | `feed_url` | Direct mapping |
149149-| `title` | `title` | Direct mapping |
150150-| `siteUrl` | `site_url` | Stored on the feed record |
151151-| `createdAt` | `added_at` | Direct mapping |
152152-| _(none)_ | `category` | Empty (Skyreader has no categories) |
146146+| Skyreader field | Glean field | Notes |
147147+| --------------- | ----------- | ----------------------------------- |
148148+| `feedUrl` | `feed_url` | Direct mapping |
149149+| `title` | `title` | Direct mapping |
150150+| `siteUrl` | `site_url` | Stored on the feed record |
151151+| `createdAt` | `added_at` | Direct mapping |
152152+| _(none)_ | `category` | Empty (Skyreader has no categories) |
153153154154If a Glean subscription already exists for the same `feed_url`, the existing one is kept. If the existing subscription has no URI (was created locally without PDS sync), the Skyreader URI/CID is backfilled.
155155···661661);
662662```
663663664664-Glean has two complementary recommendation signals:
664664+Glean uses a multi-signal recommendation system that combines subscription overlap, like patterns, social graph distance, and user behavior feedback.
665665+666666+### 7.1 Signals
665667666666-- **Subscriptions** (Jaccard similarity): "Who reads the same feeds?" → feed and people discovery
667667-- **Likes** (co-occurrence): "Who likes the same articles?" → article and feed discovery
668668+| Signal | Source | Weight (default) | Description |
669669+|--------|--------|-------------------|-------------|
670670+| Subscription | `subscriptions` | 1.0 | Jaccard over subscriber sets between similar users |
671671+| Like | `likes` | 0.5 | Time-decayed like co-occurrence (30-day half-life) |
672672+| Tag | `annotations.tags` | 0.3 | Jaccard over annotation tag sets |
673673+| Social | `follow_distances` | 0.7 | Follow distance: 1-hop=1.0, 2-hop=0.3 |
674674+| Popularity | `feeds.subscriber_count` | 0.2 | `log(1 + subscribers) / log(1 + max)` |
675675+| Category | `subscriptions.category` | 0.4 | Boost feeds matching user's existing categories |
668676669669-### 7.1 Feed Co-occurrence (Jaccard Similarity)
677677+### 7.2 Feed Co-occurrence (Jaccard Similarity)
670678671679For any two feeds, the similarity is the Jaccard index of their subscriber sets:
672680···674682J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|
675683```
676684677677-This is recomputed periodically (cron job) or incrementally when subscriptions change.
685685+Feed description text similarity is also computed (word overlap after stopword removal) and added as a boost.
678686679679-### 7.2 User Similarity
687687+### 7.3 User Similarity
680688681681-For any two users, compute Jaccard over their subscription sets:
689689+For any two users, compute Jaccard over their subscription sets, plus like co-occurrence (time-decayed) and tag overlap:
682690683691```
684684-J(U1, U2) = |feeds(U1) ∩ feeds(U2)| / |feeds(U1) ∪ feeds(U2)|
692692+J(U1, U2) = jaccard_subscriptions + 0.3 * jaccard_likes + 0.2 * jaccard_tags + follow_boost
685693```
686694687687-### 7.3 Recommendation Algorithms
695695+Like overlap uses exponential time decay: `EXP(-0.023 * age_days)` (30-day half-life).
688696689689-**Feed recommendations (on glean.at):**
697697+### 7.4 On-Demand Scoring
698698+699699+Recommendations are computed **on-demand** at query time, not pre-materialized. This avoids write amplification on every cron run.
690700691691-1. Find users with Jaccard > 0.2 (similar readers)
692692-2. Collect feeds those users subscribe to that the target user does not
693693-3. Rank by frequency (how many similar users subscribe) and average similarity
694694-4. Return top N feeds as recommendations
701701+**Feed recommendation score** (computed in SQL):
695702696703```
697697-score(feed) = Σ J(target, U) for each user U subscribed to feed
704704+score = sub_signal * w_sub
705705+ + like_signal * w_like
706706+ + social_signal * w_social
707707+ + pop_signal * w_pop
708708+ + category_signal * w_category
698709```
699710700700-**Article recommendations (on glean.at, from likes):**
711711+Where:
712712+- `sub_signal = SUM(jaccard(target, U))` for similar users U subscribed to feed
713713+- `like_signal = SUM(jaccard(target, U) * time_decay)` for likes in that feed by similar users
714714+- `social_signal = SUM(distance_weight)` from follow_distances
715715+- `pop_signal = log(1 + subscriber_count) / log(1 + max_subscribers)`
716716+- `category_signal = 1` if feed description matches user's top categories
701717702702-1. Find users who liked articles that the target user also liked
703703-2. Collect articles those users liked that the target has not
704704-3. Rank by frequency and recency
705705-4. Return top N articles as recommendations
718718+**Article recommendation score**:
706719707720```
708708-score(article) = Σ J(target, U) for each similar user U who liked the article
721721+score = like_signal * w_like
722722+ + social_signal * w_social
723723+ + recency_signal * 0.2
709724```
710725711711-**People recommendations (to follow on Bluesky):**
726726+### 7.5 User Feedback (Dismiss)
727727+728728+Users can dismiss recommendations they don't want to see again:
729729+730730+- `POST /feeds/dismiss` — dismiss a feed recommendation
731731+- `POST /articles/dismiss` — dismiss an article recommendation
732732+- Dismissals are stored locally in `dismissed_recommendations` (not on PDS)
733733+- Dismissed items are excluded from all future recommendation queries
734734+- Auto-dismiss: items shown >15 times over >30 days without action are auto-dismissed
712735713713-1. Compute user similarity for all pairs
714714-2. Return users with highest Jaccard, linking to their Bluesky profile for follow
736736+Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it.
715737716716-### 7.4 Implementation
738738+### 7.6 Auto-Tuned Signal Weights
717739718718-For the initial version, brute-force Jaccard with SQLite is sufficient (scale: ~10k users, ~100k subscriptions). The query is:
740740+Each user has a row in `user_signal_weights` with per-signal weights. When a user acts on a recommendation (subscribes, likes), the dominant signal that produced that recommendation is rewarded:
719741720720-```sql
721721-SELECT s2.feed_url, COUNT(*) as overlap_count
722722-FROM subscriptions s1
723723-JOIN subscriptions s2 ON s1.feed_url = s2.feed_url
724724-WHERE s1.user_did = ? AND s2.user_did != ?
725725-AND s2.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
726726-GROUP BY s2.feed_url
727727-ORDER BY overlap_count DESC
728728-LIMIT 20;
742742+```
743743+new_weight = MAX(0.1, MIN(3.0, old_weight * (1 + learning_rate * delta)))
729744```
730745731731-For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard in sub-linear time.
746746+- `learning_rate = 0.1`, `delta = +1` for reward, `-1` for penalty
747747+- Only activates after `minActionsTune = 5` positive actions
748748+- Defaults are used when no row exists for a user
749749+750750+### 7.7 Social Graph
751751+752752+Follow distances (1-hop and 2-hop) are pre-computed in `follow_distances` during the cron job:
753753+754754+- 1-hop: direct follows (weight 1.0)
755755+- 2-hop: friends-of-friends (weight 0.3)
756756+- 3-hop is excluded due to noise and computational cost
757757+758758+### 7.8 Diversity & Freshness
759759+760760+After scoring, diversity filtering is applied in Go (not SQL):
761761+762762+- **Domain diversity**: max 2 feeds from the same domain in results
763763+- **Category diversity**: max 3 feeds from the same category in results
764764+- This prevents recommendation clustering on a single source
765765+766766+### 7.9 Cold Start
767767+768768+New users with <5 subscriptions get a fallback strategy:
769769+770770+1. Feeds from 1-hop followed users (70% weight)
771771+2. Globally popular feeds by subscriber count (30% weight)
732772733733-### 7.5 Clustering Engine (Cron)
773773+### 7.10 Clustering Engine (Cron)
734774735775A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m):
736776737737-1. **Compute feed similarity**: Batch-update the `feed_similarity` table (Jaccard over subscriber sets)
738738-2. **Compute user similarity**: Batch-update the `user_similarity` table (Jaccard over subscription sets, boosted by follow relationships)
739739-3. **Generate feed recommendations**: Materialize top feed recommendations per user into `user_feed_recommendations`
740740-4. **Generate article recommendations**: Materialize top article recommendations per user into `user_article_recommendations`
777777+1. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + description similarity)
778778+2. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)
779779+3. **Compute follow distances**: 1-hop and 2-hop from `follows` table
780780+4. **Compute signal profiles**: Per-user category/tag/like summaries
781781+5. **Auto-dismiss stale**: Dismiss items shown >15 times over >30 days without action
741782742783Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
743784785785+### 7.11 New Database Tables
786786+744787```sql
745745-CREATE TABLE user_feed_recommendations (
746746- user_did TEXT NOT NULL REFERENCES users(did),
747747- feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
748748- score REAL NOT NULL,
749749- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
750750- PRIMARY KEY (user_did, feed_url)
788788+CREATE TABLE dismissed_recommendations (
789789+ user_did TEXT NOT NULL REFERENCES users(did),
790790+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
791791+ target_id TEXT NOT NULL,
792792+ reason TEXT,
793793+ dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
794794+ PRIMARY KEY (user_did, target_type, target_id)
751795);
752796753753-CREATE TABLE user_article_recommendations (
754754- user_did TEXT NOT NULL REFERENCES users(did),
755755- feed_url TEXT NOT NULL,
756756- article_url TEXT NOT NULL,
757757- score REAL NOT NULL,
758758- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
759759- PRIMARY KEY (user_did, feed_url, article_url)
797797+CREATE TABLE recommendation_impressions (
798798+ user_did TEXT NOT NULL REFERENCES users(did),
799799+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
800800+ target_id TEXT NOT NULL,
801801+ first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
802802+ last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
803803+ shown_count INTEGER NOT NULL DEFAULT 1,
804804+ acted BOOLEAN NOT NULL DEFAULT 0,
805805+ PRIMARY KEY (user_did, target_type, target_id)
806806+);
807807+808808+CREATE TABLE follow_distances (
809809+ user_a TEXT NOT NULL,
810810+ user_b TEXT NOT NULL,
811811+ distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
812812+ PRIMARY KEY (user_a, user_b)
813813+);
814814+815815+CREATE TABLE user_signal_weights (
816816+ user_did TEXT PRIMARY KEY REFERENCES users(did),
817817+ w_sub REAL NOT NULL DEFAULT 1.0,
818818+ w_like REAL NOT NULL DEFAULT 0.5,
819819+ w_tag REAL NOT NULL DEFAULT 0.3,
820820+ w_social REAL NOT NULL DEFAULT 0.7,
821821+ w_pop REAL NOT NULL DEFAULT 0.2,
822822+ w_category REAL NOT NULL DEFAULT 0.4,
823823+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
824824+);
825825+826826+CREATE TABLE user_signal_profiles (
827827+ user_did TEXT PRIMARY KEY REFERENCES users(did),
828828+ total_likes INTEGER NOT NULL DEFAULT 0,
829829+ total_tags INTEGER NOT NULL DEFAULT 0,
830830+ top_categories TEXT,
831831+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
760832);
761833```
762834···778850| `/feeds/add` | POST | Add a single feed URL |
779851| `/feeds/remove` | DELETE | Remove a feed |
780852| `/feeds/refresh` | POST | Refresh all subscribed feeds |
781781-| `/feeds/clear` | POST | Clear all subscriptions |
853853+| `/feeds/clear` | POST | Clear all subscriptions |
854854+| `/feeds/dismiss` | POST | Dismiss a feed recommendation |
782855| `/articles` | GET | Read articles (paginated, filterable by feed) |
783856| `/articles/{id}` | GET | Article detail view |
784857| `/articles/{id}/read` | POST | Mark article as read |
785858| `/articles/{id}/unread` | POST | Mark article as unread |
786859| `/articles/{id}/like` | POST | Like an article |
787860| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL |
788788-| `/articles/mark-all-read` | POST | Mark all articles as read |
789789-| `/trending` | GET | Community feed: articles ranked by likes |
861861+| `/articles/mark-all-read` | POST | Mark all articles as read |
862862+| `/articles/dismiss` | POST | Dismiss an article recommendation |
863863+| `/trending` | GET | Community feed: articles ranked by likes |
790864| `/library` | GET | Liked articles and annotations |
791865| `/library/create` | POST | Create annotation on an article |
792866| `/library/{id}/delete` | POST | Delete an annotation |
···846920│ │ └── metrics.go # Prometheus metrics definitions
847921│ ├── cluster/
848922│ │ ├── jaccard.go # Jaccard similarity computation
849849-│ │ ├── recommender.go # Feed + people recommendation queries
923923+│ │ ├── recommender.go # Feed + people recommendation queries (on-demand)
924924+│ │ ├── scoring.go # Multi-signal composite scoring queries
925925+│ │ ├── social.go # Follow-distance computation (1-2 hop)
926926+│ │ ├── dismiss.go # Dismiss + impression tracking
927927+│ │ ├── weights.go # Bandit-style signal weight auto-tuning
928928+│ │ ├── diversity.go # Post-query domain/category diversity filtering
850929│ │ └── cron.go # Background recomputation scheduler
851930│ ├── server/
852931│ │ ├── server.go # HTTP server, router setup
···99310729941073## 13. Future Considerations
9951074996996-- **MinHash/LSH**: Replace brute-force Jaccard when user count exceeds ~50k
997997-- **Full-text search**: Add FTS5 virtual table on articles for search
9981075- **Email digest**: Periodic email with top articles from subscribed feeds
+26-54
internal/atproto/xrpc.go
···88 "strings"
991010 "github.com/go-chi/chi/v5"
1111+1212+ "pkg.rbrt.fr/glean/internal/cluster"
1113)
12141315type XRPCHandler struct {
1414- db *sql.DB
1616+ db *sql.DB
1717+ engine *cluster.Engine
1518}
16191717-func NewXRPCHandler(db *sql.DB) *XRPCHandler {
1818- return &XRPCHandler{db: db}
2020+func NewXRPCHandler(db *sql.DB, engine *cluster.Engine) *XRPCHandler {
2121+ return &XRPCHandler{db: db, engine: engine}
1922}
20232124func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
···303306 repo := r.URL.Query().Get("repo")
304307 limit := min(parseIntParam(r, "limit", 20), 50)
305308306306- feedRows, err := h.db.QueryContext(r.Context(), `
307307- SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score
308308- FROM user_feed_recommendations r
309309- JOIN feeds f ON r.feed_url = f.feed_url
310310- WHERE r.user_did = ?
311311- ORDER BY r.score DESC
312312- LIMIT ?
313313- `, repo, limit)
309309+ ctx := r.Context()
310310+311311+ feedRecs, err := h.engine.GetFeedRecommendations(ctx, repo, limit)
314312 if err != nil {
315313 http.Error(w, err.Error(), http.StatusInternalServerError)
316314 return
317315 }
318318- defer feedRows.Close()
319316320320- feeds := make([]RecommendedFeed, 0)
321321- for feedRows.Next() {
322322- var feedURL, title, siteURL, description string
323323- var subscriberCount int
324324- var score float64
325325- if err := feedRows.Scan(&feedURL, &title, &siteURL, &description, &subscriberCount, &score); err != nil {
326326- http.Error(w, err.Error(), http.StatusInternalServerError)
327327- return
328328- }
317317+ feeds := make([]RecommendedFeed, 0, len(feedRecs))
318318+ for _, rec := range feedRecs {
329319 feeds = append(feeds, RecommendedFeed{
330330- FeedURL: feedURL,
331331- Title: title,
332332- SiteURL: siteURL,
333333- Description: description,
334334- SubscriberCount: subscriberCount,
335335- Score: score,
320320+ FeedURL: rec.FeedURL,
321321+ Title: rec.Title,
322322+ SiteURL: rec.SiteURL,
323323+ Description: rec.Description,
324324+ SubscriberCount: rec.SubscriberCount,
325325+ Score: rec.Score,
336326 })
337327 }
338328339339- peopleRows, err := h.db.QueryContext(r.Context(), `
340340- SELECT u.did, u.handle, u.display_name, u.avatar_url, s.jaccard, s.common_feeds
341341- FROM user_similarity s
342342- JOIN users u ON (
343343- CASE WHEN s.user_a = ? THEN s.user_b ELSE s.user_a END
344344- ) = u.did
345345- WHERE s.user_a = ? OR s.user_b = ?
346346- ORDER BY s.jaccard DESC
347347- LIMIT ?
348348- `, repo, repo, repo, limit)
329329+ peopleRecs, err := h.engine.GetPeopleRecommendations(ctx, repo, limit)
349330 if err != nil {
350331 http.Error(w, err.Error(), http.StatusInternalServerError)
351332 return
352333 }
353353- defer peopleRows.Close()
354334355355- people := make([]RecommendedPerson, 0)
356356- for peopleRows.Next() {
357357- var did, handle string
358358- var displayName, avatar sql.NullString
359359- var jaccard float64
360360- var commonFeeds int
361361- if err := peopleRows.Scan(&did, &handle, &displayName, &avatar, &jaccard, &commonFeeds); err != nil {
362362- http.Error(w, err.Error(), http.StatusInternalServerError)
363363- return
364364- }
335335+ people := make([]RecommendedPerson, 0, len(peopleRecs))
336336+ for _, rec := range peopleRecs {
365337 people = append(people, RecommendedPerson{
366366- DID: did,
367367- Handle: handle,
368368- DisplayName: displayName.String,
369369- Avatar: avatar.String,
370370- Jaccard: jaccard,
371371- CommonFeeds: commonFeeds,
338338+ DID: rec.DID,
339339+ Handle: rec.Handle,
340340+ DisplayName: rec.DisplayName,
341341+ Avatar: rec.AvatarURL,
342342+ Jaccard: rec.Jaccard,
343343+ CommonFeeds: rec.CommonFeeds,
372344 })
373345 }
374346
···11-package cluster
22-33-import (
44- "context"
55- "database/sql"
66-)
77-88-type FeedRecommendation struct {
99- FeedURL string
1010- Title string
1111- SiteURL string
1212- Description string
1313- SubscriberCount int
1414- FaviconURL string
1515- Score float64
1616-}
1717-1818-type PersonRecommendation struct {
1919- DID string
2020- Handle string
2121- DisplayName string
2222- AvatarURL string
2323- Jaccard float64
2424- CommonFeeds int
2525- CommonLikes int
2626- CommonTags int
2727-}
2828-2929-type ArticleRecommendation struct {
3030- ArticleID int64
3131- Title string
3232- URL string
3333- FeedURL string
3434- FeedTitle string
3535- Author string
3636- Summary string
3737- Published sql.NullTime
3838- Score float64
3939-}
4040-4141-type SimilarFeed struct {
4242- FeedURL string
4343- Title string
4444- SiteURL string
4545- Description string
4646- FeedType string
4747- Jaccard float64
4848-}
4949-5050-func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
5151- rows, err := e.db.QueryContext(ctx, `
5252- SELECT r.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
5353- COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, ''), r.score
5454- FROM user_feed_recommendations r
5555- JOIN feeds f ON f.feed_url = r.feed_url
5656- WHERE r.user_did = ?
5757- AND r.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
5858- ORDER BY r.score DESC
5959- LIMIT ?
6060- `, userDID, userDID, limit)
6161- if err != nil {
6262- return nil, err
6363- }
6464- defer rows.Close()
6565-6666- var results []*FeedRecommendation
6767- for rows.Next() {
6868- rec := &FeedRecommendation{}
6969- if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
7070- &rec.SubscriberCount, &rec.FaviconURL, &rec.Score); err != nil {
7171- return nil, err
7272- }
7373- results = append(results, rec)
7474- }
7575- return results, rows.Err()
7676-}
7777-7878-func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
7979- rows, err := e.db.QueryContext(ctx, `
8080- SELECT u.did, u.handle, COALESCE(u.display_name, ''), COALESCE(u.avatar_url, ''),
8181- sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0)
8282- FROM (
8383- SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_a = ?
8484- UNION ALL
8585- SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_b = ?
8686- ) sim
8787- JOIN users u ON u.did = sim.peer_did
8888- WHERE u.handle IS NOT NULL AND u.handle != ''
8989- ORDER BY sim.jaccard DESC
9090- LIMIT ?
9191- `, userDID, userDID, limit)
9292- if err != nil {
9393- return nil, err
9494- }
9595- defer rows.Close()
9696-9797- var results []*PersonRecommendation
9898- for rows.Next() {
9999- rec := &PersonRecommendation{}
100100- if err := rows.Scan(&rec.DID, &rec.Handle, &rec.DisplayName, &rec.AvatarURL,
101101- &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags); err != nil {
102102- return nil, err
103103- }
104104- results = append(results, rec)
105105- }
106106- return results, rows.Err()
107107-}
108108-109109-func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
110110- rows, err := e.db.QueryContext(ctx, `
111111- SELECT a.id, a.title, COALESCE(a.url, ''), r.feed_url, COALESCE(f.title, ''),
112112- COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, r.score
113113- FROM user_article_recommendations r
114114- JOIN articles a ON a.feed_url = r.feed_url AND a.url = r.article_url
115115- LEFT JOIN feeds f ON f.feed_url = r.feed_url
116116- WHERE r.user_did = ?
117117- ORDER BY r.score DESC
118118- LIMIT ?
119119- `, userDID, limit)
120120- if err != nil {
121121- return nil, err
122122- }
123123- defer rows.Close()
124124-125125- var recs []*ArticleRecommendation
126126- for rows.Next() {
127127- rec := &ArticleRecommendation{}
128128- if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle,
129129- &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil {
130130- return nil, err
131131- }
132132- recs = append(recs, rec)
133133- }
134134- return recs, rows.Err()
135135-}
136136-137137-func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*SimilarFeed, error) {
138138- rows, err := e.db.QueryContext(ctx, `
139139- SELECT f.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
140140- COALESCE(f.description, ''), COALESCE(f.feed_type, ''), sim.jaccard
141141- FROM (
142142- SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ?
143143- UNION ALL
144144- SELECT feed_a AS peer_url, jaccard FROM feed_similarity WHERE feed_b = ?
145145- ) sim
146146- JOIN feeds f ON f.feed_url = sim.peer_url
147147- ORDER BY sim.jaccard DESC
148148- LIMIT ?
149149- `, feedURL, feedURL, limit)
150150- if err != nil {
151151- return nil, err
152152- }
153153- defer rows.Close()
154154-155155- var results []*SimilarFeed
156156- for rows.Next() {
157157- rec := &SimilarFeed{}
158158- if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
159159- &rec.FeedType, &rec.Jaccard); err != nil {
160160- return nil, err
161161- }
162162- results = append(results, rec)
163163- }
164164- return results, rows.Err()
165165-}
+376
internal/cluster/scoring.go
···11+package cluster
22+33+import (
44+ "context"
55+ "database/sql"
66+)
77+88+type FeedRecommendation struct {
99+ FeedURL string
1010+ Title string
1111+ SiteURL string
1212+ Description string
1313+ SubscriberCount int
1414+ FaviconURL string
1515+ Score float64
1616+}
1717+1818+type PersonRecommendation struct {
1919+ DID string
2020+ Handle string
2121+ DisplayName string
2222+ AvatarURL string
2323+ Jaccard float64
2424+ CommonFeeds int
2525+ CommonLikes int
2626+ CommonTags int
2727+}
2828+2929+type ArticleRecommendation struct {
3030+ ArticleID int64
3131+ Title string
3232+ URL string
3333+ FeedURL string
3434+ FeedTitle string
3535+ Author string
3636+ Summary string
3737+ Published sql.NullTime
3838+ Score float64
3939+}
4040+4141+func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
4242+ subCount := 0
4343+ _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
4444+4545+ if subCount < 5 {
4646+ recs, err := e.ColdStartRecommendations(ctx, userDID, limit*2)
4747+ if err == nil && len(recs) > 0 {
4848+ return ApplyDiversity(recs, limit), nil
4949+ }
5050+ }
5151+5252+ recs, err := e.ComputeFeedRecommendationsOnDemand(ctx, userDID, limit*2)
5353+ if err != nil {
5454+ return nil, err
5555+ }
5656+5757+ return ApplyDiversity(recs, limit), nil
5858+}
5959+6060+func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
6161+ return e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit)
6262+}
6363+6464+func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
6565+ return e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)
6666+}
6767+6868+type SignalWeights struct {
6969+ WSub float64
7070+ WLike float64
7171+ WTag float64
7272+ WSocial float64
7373+ WPop float64
7474+ WCategory float64
7575+}
7676+7777+func defaultWeights() SignalWeights {
7878+ return SignalWeights{
7979+ WSub: 1.0,
8080+ WLike: 0.5,
8181+ WTag: 0.3,
8282+ WSocial: 0.7,
8383+ WPop: 0.2,
8484+ WCategory: 0.4,
8585+ }
8686+}
8787+8888+func (e *Engine) GetWeights(ctx context.Context, userDID string) SignalWeights {
8989+ w := defaultWeights()
9090+ var dbW SignalWeights
9191+ err := e.db.QueryRowContext(ctx, `
9292+ SELECT w_sub, w_like, w_tag, w_social, w_pop, w_category
9393+ FROM user_signal_weights WHERE user_did = ?
9494+ `, userDID).Scan(&dbW.WSub, &dbW.WLike, &dbW.WTag, &dbW.WSocial, &dbW.WPop, &dbW.WCategory)
9595+ if err == nil {
9696+ return dbW
9797+ }
9898+ return w
9999+}
100100+101101+func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
102102+ w := e.GetWeights(ctx, userDID)
103103+104104+ rows, err := e.db.QueryContext(ctx, `
105105+ WITH similar_users AS (
106106+ SELECT user_b AS peer, jaccard FROM user_similarity WHERE user_a = ? AND jaccard > 0.15
107107+ UNION ALL
108108+ SELECT user_a AS peer, jaccard FROM user_similarity WHERE user_b = ? AND jaccard > 0.15
109109+ ),
110110+ candidate_feeds AS (
111111+ SELECT s.feed_url,
112112+ SUM(su.jaccard) AS sub_signal
113113+ FROM similar_users su
114114+ JOIN subscriptions s ON s.user_did = su.peer
115115+ WHERE s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
116116+ AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
117117+ GROUP BY s.feed_url
118118+ ),
119119+ like_signals AS (
120120+ SELECT s.feed_url,
121121+ SUM(su.jaccard * EXP(-0.023 * CAST(julianday('now') - julianday(l.created_at) AS REAL))) AS like_signal
122122+ FROM similar_users su
123123+ JOIN likes l ON l.author_did = su.peer
124124+ JOIN subscriptions s ON s.feed_url = l.feed_url
125125+ WHERE s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
126126+ AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
127127+ GROUP BY s.feed_url
128128+ ),
129129+ social_boost AS (
130130+ SELECT s.feed_url,
131131+ SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social
132132+ FROM follow_distances fd
133133+ JOIN subscriptions s ON s.user_did = fd.user_b
134134+ WHERE fd.user_a = ?
135135+ AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
136136+ AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
137137+ GROUP BY s.feed_url
138138+ ),
139139+ category_counts AS (
140140+ SELECT category, COUNT(*) AS cnt
141141+ FROM subscriptions WHERE user_did = ? AND category IS NOT NULL AND category != ''
142142+ GROUP BY category
143143+ ),
144144+ max_subs AS (
145145+ SELECT CAST(COALESCE(MAX(subscriber_count), 1) AS REAL) AS m FROM feeds
146146+ )
147147+ SELECT cf.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
148148+ COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, ''),
149149+ COALESCE(cf.sub_signal, 0) * ?
150150+ + COALESCE(ls.like_signal, 0) * ?
151151+ + COALESCE(sb.social, 0) * ?
152152+ + COALESCE(LOG(1 + CAST(f.subscriber_count AS REAL)) / LOG(1 + ms.m), 0) * ?
153153+ + CASE WHEN f.description IS NOT NULL AND EXISTS (
154154+ SELECT 1 FROM category_counts cc
155155+ WHERE LOWER(f.description) LIKE '%' || LOWER(cc.category) || '%'
156156+ ) THEN ? ELSE 0 END
157157+ AS score
158158+ FROM candidate_feeds cf
159159+ JOIN feeds f ON f.feed_url = cf.feed_url
160160+ LEFT JOIN like_signals ls ON ls.feed_url = cf.feed_url
161161+ LEFT JOIN social_boost sb ON sb.feed_url = cf.feed_url
162162+ CROSS JOIN max_subs ms
163163+ ORDER BY score DESC
164164+ LIMIT ?
165165+ `, userDID, userDID, userDID, userDID, userDID, userDID, userDID, userDID, userDID, userDID,
166166+ w.WSub, w.WLike, w.WSocial, w.WPop, w.WCategory, limit)
167167+ if err != nil {
168168+ return nil, err
169169+ }
170170+ defer rows.Close()
171171+172172+ var results []*FeedRecommendation
173173+ for rows.Next() {
174174+ rec := &FeedRecommendation{}
175175+ if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
176176+ &rec.SubscriberCount, &rec.FaviconURL, &rec.Score); err != nil {
177177+ return nil, err
178178+ }
179179+ results = append(results, rec)
180180+ }
181181+ return results, rows.Err()
182182+}
183183+184184+func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
185185+ w := e.GetWeights(ctx, userDID)
186186+187187+ rows, err := e.db.QueryContext(ctx, `
188188+ WITH similar_users AS (
189189+ SELECT user_b AS peer, jaccard FROM user_similarity WHERE user_a = ? AND jaccard > 0.15
190190+ UNION ALL
191191+ SELECT user_a AS peer, jaccard FROM user_similarity WHERE user_b = ? AND jaccard > 0.15
192192+ ),
193193+ liked_articles AS (
194194+ SELECT l.feed_url, l.article_url,
195195+ SUM(su.jaccard * EXP(-0.023 * CAST(julianday('now') - julianday(l.created_at) AS REAL))) AS like_signal
196196+ FROM similar_users su
197197+ JOIN likes l ON l.author_did = su.peer
198198+ WHERE NOT EXISTS (
199199+ SELECT 1 FROM likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
200200+ )
201201+ AND NOT EXISTS (
202202+ SELECT 1 FROM dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url
203203+ )
204204+ GROUP BY l.feed_url, l.article_url
205205+ ),
206206+ social_likes AS (
207207+ SELECT l.feed_url, l.article_url,
208208+ SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social
209209+ FROM follow_distances fd
210210+ JOIN likes l ON l.author_did = fd.user_b
211211+ WHERE fd.user_a = ?
212212+ AND NOT EXISTS (
213213+ SELECT 1 FROM likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
214214+ )
215215+ GROUP BY l.feed_url, l.article_url
216216+ )
217217+ SELECT a.id, a.title, COALESCE(a.url, ''), la.feed_url, COALESCE(f.title, ''),
218218+ COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published,
219219+ COALESCE(la.like_signal, 0) * ?
220220+ + COALESCE(sl.social, 0) * ?
221221+ + EXP(-0.023 * CAST(julianday('now') - julianday(a.published) AS REAL)) * 0.2
222222+ AS score
223223+ FROM liked_articles la
224224+ JOIN articles a ON a.feed_url = la.feed_url AND a.url = la.article_url
225225+ LEFT JOIN feeds f ON f.feed_url = la.feed_url
226226+ LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url
227227+ ORDER BY score DESC
228228+ LIMIT ?
229229+ `, userDID, userDID, userDID, userDID, userDID, userDID, w.WLike, w.WSocial, limit)
230230+ if err != nil {
231231+ return nil, err
232232+ }
233233+ defer rows.Close()
234234+235235+ var recs []*ArticleRecommendation
236236+ for rows.Next() {
237237+ rec := &ArticleRecommendation{}
238238+ if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle,
239239+ &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil {
240240+ return nil, err
241241+ }
242242+ recs = append(recs, rec)
243243+ }
244244+ return recs, rows.Err()
245245+}
246246+247247+func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
248248+ rows, err := e.db.QueryContext(ctx, `
249249+ SELECT u.did, u.handle, COALESCE(u.display_name, ''), COALESCE(u.avatar_url, ''),
250250+ sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0)
251251+ FROM (
252252+ SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_a = ?
253253+ UNION ALL
254254+ SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_b = ?
255255+ ) sim
256256+ JOIN users u ON u.did = sim.peer_did
257257+ WHERE u.handle IS NOT NULL AND u.handle != ''
258258+ ORDER BY sim.jaccard DESC
259259+ LIMIT ?
260260+ `, userDID, userDID, limit)
261261+ if err != nil {
262262+ return nil, err
263263+ }
264264+ defer rows.Close()
265265+266266+ var results []*PersonRecommendation
267267+ for rows.Next() {
268268+ rec := &PersonRecommendation{}
269269+ if err := rows.Scan(&rec.DID, &rec.Handle, &rec.DisplayName, &rec.AvatarURL,
270270+ &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags); err != nil {
271271+ return nil, err
272272+ }
273273+ results = append(results, rec)
274274+ }
275275+ return results, rows.Err()
276276+}
277277+278278+func (e *Engine) ComputeSignalProfiles(ctx context.Context) error {
279279+ tx, err := e.db.BeginTx(ctx, nil)
280280+ if err != nil {
281281+ return err
282282+ }
283283+ defer func() { _ = tx.Rollback() }()
284284+285285+ if _, err := tx.ExecContext(ctx, `DELETE FROM user_signal_profiles`); err != nil {
286286+ return err
287287+ }
288288+289289+ _, err = tx.ExecContext(ctx, `
290290+ INSERT INTO user_signal_profiles (user_did, total_likes, total_tags, top_categories)
291291+ SELECT
292292+ u.did,
293293+ (SELECT COUNT(*) FROM likes WHERE author_did = u.did),
294294+ COALESCE((SELECT COUNT(DISTINCT TRIM(value))
295295+ FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
296296+ WHERE author_did = u.did AND tags IS NOT NULL AND tags != ''
297297+ ), 0),
298298+ (SELECT '[' || GROUP_CONCAT('{"c":"' || category || '","n":"' || CAST(cnt AS TEXT) || '}') || ']'
299299+ FROM (
300300+ SELECT category, COUNT(*) AS cnt
301301+ FROM subscriptions WHERE user_did = u.did AND category IS NOT NULL AND category != ''
302302+ GROUP BY category ORDER BY cnt DESC LIMIT 5
303303+ )
304304+ )
305305+ FROM users u
306306+ `)
307307+ if err != nil {
308308+ return err
309309+ }
310310+311311+ e.logger.Info("signal profiles computed")
312312+ return tx.Commit()
313313+}
314314+315315+func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
316316+ subCount := 0
317317+ _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
318318+ if subCount >= 5 {
319319+ return nil, nil
320320+ }
321321+322322+ rows, err := e.db.QueryContext(ctx, `
323323+ WITH followed_feeds AS (
324324+ SELECT s.feed_url, 1.0 AS weight
325325+ FROM follow_distances fd
326326+ JOIN subscriptions s ON s.user_did = fd.user_b
327327+ WHERE fd.user_a = ? AND fd.distance = 1
328328+ AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
329329+ AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
330330+ ),
331331+ popular_feeds AS (
332332+ SELECT feed_url, subscriber_count,
333333+ LOG(1 + CAST(subscriber_count AS REAL)) / LOG(1 + CAST((SELECT COALESCE(MAX(subscriber_count), 1) FROM feeds) AS REAL)) AS pop_score
334334+ FROM feeds
335335+ WHERE subscriber_count > 0
336336+ AND feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
337337+ AND feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
338338+ ORDER BY subscriber_count DESC
339339+ LIMIT 50
340340+ ),
341341+ all_candidates AS (
342342+ SELECT feed_url, MAX(weight) AS weight FROM (
343343+ SELECT feed_url, weight FROM followed_feeds
344344+ UNION ALL
345345+ SELECT feed_url, pop_score AS weight FROM popular_feeds
346346+ )
347347+ GROUP BY feed_url
348348+ )
349349+ SELECT ac.feed_url,
350350+ COALESCE(f.title, ''),
351351+ COALESCE(f.site_url, ''),
352352+ COALESCE(f.description, ''),
353353+ f.subscriber_count,
354354+ COALESCE(f.favicon_url, ''),
355355+ ac.weight AS score
356356+ FROM all_candidates ac
357357+ JOIN feeds f ON f.feed_url = ac.feed_url
358358+ ORDER BY score DESC
359359+ LIMIT ?
360360+ `, userDID, userDID, userDID, userDID, userDID, limit)
361361+ if err != nil {
362362+ return nil, err
363363+ }
364364+ defer rows.Close()
365365+366366+ var results []*FeedRecommendation
367367+ for rows.Next() {
368368+ rec := &FeedRecommendation{}
369369+ if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
370370+ &rec.SubscriberCount, &rec.FaviconURL, &rec.Score); err != nil {
371371+ return nil, err
372372+ }
373373+ results = append(results, rec)
374374+ }
375375+ return results, rows.Err()
376376+}
+63
internal/cluster/social.go
···11+package cluster
22+33+import (
44+ "context"
55+)
66+77+func (e *Engine) ComputeFollowDistances(ctx context.Context) error {
88+ tx, err := e.db.BeginTx(ctx, nil)
99+ if err != nil {
1010+ return err
1111+ }
1212+ defer func() { _ = tx.Rollback() }()
1313+1414+ if _, err := tx.ExecContext(ctx, `DELETE FROM follow_distances`); err != nil {
1515+ return err
1616+ }
1717+1818+ if _, err := tx.ExecContext(ctx, `
1919+ INSERT INTO follow_distances (user_a, user_b, distance)
2020+ SELECT user_did, target_did, 1
2121+ FROM follows
2222+ WHERE user_did != target_did
2323+ `); err != nil {
2424+ return err
2525+ }
2626+2727+ if _, err := tx.ExecContext(ctx, `
2828+ INSERT OR IGNORE INTO follow_distances (user_a, user_b, distance)
2929+ SELECT f1.user_did, f2.target_did, 2
3030+ FROM follows f1
3131+ JOIN follows f2 ON f1.target_did = f2.user_did
3232+ WHERE f1.user_did != f2.target_did
3333+ `); err != nil {
3434+ return err
3535+ }
3636+3737+ e.logger.Info("follow distances computed")
3838+ return tx.Commit()
3939+}
4040+4141+func (e *Engine) ComputeFollowDistancesIncremental(ctx context.Context) error {
4242+ var maxFollowed string
4343+ err := e.db.QueryRowContext(ctx, `
4444+ SELECT COALESCE(MAX(followed_at), '1970-01-01') FROM follows
4545+ `).Scan(&maxFollowed)
4646+ if err != nil {
4747+ return err
4848+ }
4949+5050+ var lastComputed string
5151+ err = e.db.QueryRowContext(ctx, `
5252+ SELECT COALESCE(MAX(updated_at), '1970-01-01') FROM user_similarity
5353+ `).Scan(&lastComputed)
5454+ if err != nil {
5555+ return err
5656+ }
5757+5858+ if maxFollowed <= lastComputed {
5959+ return nil
6060+ }
6161+6262+ return e.ComputeFollowDistances(ctx)
6363+}
+93
internal/cluster/weights.go
···11+package cluster
22+33+import (
44+ "context"
55+)
66+77+const (
88+ learningRate = 0.1
99+ weightMin = 0.1
1010+ weightMax = 3.0
1111+ minActionsTune = 5
1212+)
1313+1414+func (e *Engine) RewardSignal(ctx context.Context, userDID string, signal string) {
1515+ e.adjustWeight(ctx, userDID, signal, 1.0)
1616+}
1717+1818+func (e *Engine) PenalizeSignal(ctx context.Context, userDID string, signal string) {
1919+ e.adjustWeight(ctx, userDID, signal, -1.0)
2020+}
2121+2222+func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string, delta float64) {
2323+ var actedCount int
2424+ _ = e.db.QueryRowContext(ctx, `
2525+ SELECT COUNT(*) FROM recommendation_impressions WHERE user_did = ? AND acted = 1
2626+ `, userDID).Scan(&actedCount)
2727+ if actedCount < minActionsTune {
2828+ return
2929+ }
3030+3131+ var exists int
3232+ _ = e.db.QueryRowContext(ctx, `SELECT 1 FROM user_signal_weights WHERE user_did = ?`, userDID).Scan(&exists)
3333+3434+ if exists == 0 {
3535+ _, _ = e.db.ExecContext(ctx, `
3636+ INSERT INTO user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category)
3737+ VALUES (?, 1.0, 0.5, 0.3, 0.7, 0.2, 0.4)
3838+ `, userDID)
3939+ }
4040+4141+ column := signalToColumn(signal)
4242+ if column == "" {
4343+ return
4444+ }
4545+4646+ adj := learningRate * delta
4747+ _, _ = e.db.ExecContext(ctx, `
4848+ UPDATE user_signal_weights SET
4949+ `+column+` = MAX(?, MIN(?, `+column+` * (1 + ?))),
5050+ updated_at = CURRENT_TIMESTAMP
5151+ WHERE user_did = ?
5252+ `, weightMin, weightMax, adj, userDID)
5353+}
5454+5555+func signalToColumn(signal string) string {
5656+ switch signal {
5757+ case "sub":
5858+ return "w_sub"
5959+ case "like":
6060+ return "w_like"
6161+ case "tag":
6262+ return "w_tag"
6363+ case "social":
6464+ return "w_social"
6565+ case "pop":
6666+ return "w_pop"
6767+ case "category":
6868+ return "w_category"
6969+ default:
7070+ return ""
7171+ }
7272+}
7373+7474+func (e *Engine) GetDominantSignal(w SignalWeights) string {
7575+ signals := map[string]float64{
7676+ "sub": w.WSub,
7777+ "like": w.WLike,
7878+ "tag": w.WTag,
7979+ "social": w.WSocial,
8080+ "pop": w.WPop,
8181+ "category": w.WCategory,
8282+ }
8383+8484+ var best string
8585+ bestVal := -1.0
8686+ for s, v := range signals {
8787+ if v > bestVal {
8888+ bestVal = v
8989+ best = s
9090+ }
9191+ }
9292+ return best
9393+}
+83-16
internal/db/db.go
···11package db
2233import (
44+ "context"
45 "database/sql"
55- _ "github.com/mattn/go-sqlite3"
66+ "math"
67 "strings"
78 "time"
99+1010+ "github.com/mattn/go-sqlite3"
811)
9121013func NullStr(s string) sql.NullString {
···3942 db.SetMaxOpenConns(1)
4043 db.SetMaxIdleConns(2)
4144 db.SetConnMaxLifetime(30 * time.Minute)
4545+4646+ conn, err := db.Conn(context.Background())
4747+ if err != nil {
4848+ db.Close()
4949+ return nil, err
5050+ }
5151+5252+ err = conn.Raw(func(driverConn any) error {
5353+ sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn)
5454+ if !ok {
5555+ return nil
5656+ }
5757+ if err := sqliteConn.RegisterFunc("exp", func(x float64) float64 { return math.Exp(x) }, true); err != nil {
5858+ return err
5959+ }
6060+ if err := sqliteConn.RegisterFunc("log", func(x float64) float64 { return math.Log(x) }, true); err != nil {
6161+ return err
6262+ }
6363+ return nil
6464+ })
6565+ _ = conn.Close()
6666+ if err != nil {
6767+ db.Close()
6868+ return nil, err
6969+ }
42704371 if err := initSchema(db); err != nil {
4472 db.Close()
···158186 PRIMARY KEY (user_a, user_b),
159187 CHECK(user_a < user_b)
160188 )`,
161161- `CREATE TABLE IF NOT EXISTS user_feed_recommendations (
162162- user_did TEXT NOT NULL REFERENCES users(did),
163163- feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
164164- score REAL NOT NULL,
165165- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
166166- PRIMARY KEY (user_did, feed_url)
167167- )`,
168168- `CREATE TABLE IF NOT EXISTS user_article_recommendations (
169169- user_did TEXT NOT NULL REFERENCES users(did),
170170- feed_url TEXT NOT NULL,
171171- article_url TEXT NOT NULL,
172172- score REAL NOT NULL,
173173- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
174174- PRIMARY KEY (user_did, feed_url, article_url)
175175- )`,
176189 `CREATE TABLE IF NOT EXISTS follows (
177190 user_did TEXT NOT NULL REFERENCES users(did),
178191 target_did TEXT NOT NULL,
···208221 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
209222 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
210223 `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`,
224224+225225+ `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
226226+ user_did TEXT NOT NULL REFERENCES users(did),
227227+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
228228+ target_id TEXT NOT NULL,
229229+ reason TEXT,
230230+ dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
231231+ PRIMARY KEY (user_did, target_type, target_id)
232232+ )`,
233233+234234+ `CREATE TABLE IF NOT EXISTS recommendation_impressions (
235235+ user_did TEXT NOT NULL REFERENCES users(did),
236236+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
237237+ target_id TEXT NOT NULL,
238238+ first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
239239+ last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
240240+ shown_count INTEGER NOT NULL DEFAULT 1,
241241+ acted BOOLEAN NOT NULL DEFAULT 0,
242242+ PRIMARY KEY (user_did, target_type, target_id)
243243+ )`,
244244+245245+ `CREATE TABLE IF NOT EXISTS follow_distances (
246246+ user_a TEXT NOT NULL,
247247+ user_b TEXT NOT NULL,
248248+ distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
249249+ PRIMARY KEY (user_a, user_b)
250250+ )`,
251251+252252+ `CREATE TABLE IF NOT EXISTS user_signal_weights (
253253+ user_did TEXT PRIMARY KEY REFERENCES users(did),
254254+ w_sub REAL NOT NULL DEFAULT 1.0,
255255+ w_like REAL NOT NULL DEFAULT 0.5,
256256+ w_tag REAL NOT NULL DEFAULT 0.3,
257257+ w_social REAL NOT NULL DEFAULT 0.7,
258258+ w_pop REAL NOT NULL DEFAULT 0.2,
259259+ w_category REAL NOT NULL DEFAULT 0.4,
260260+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
261261+ )`,
262262+263263+ `CREATE TABLE IF NOT EXISTS user_signal_profiles (
264264+ user_did TEXT PRIMARY KEY REFERENCES users(did),
265265+ total_likes INTEGER NOT NULL DEFAULT 0,
266266+ total_tags INTEGER NOT NULL DEFAULT 0,
267267+ top_categories TEXT,
268268+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
269269+ )`,
270270+271271+ `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
272272+ `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
273273+ `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
274274+ `CREATE INDEX IF NOT EXISTS idx_follow_distances_b ON follow_distances(user_b)`,
275275+ `CREATE INDEX IF NOT EXISTS idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
276276+ `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,
277277+ `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
211278 `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,
212279 `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
213280 `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN