nandi/gleanpublic⑂ Fork 0
⑂ 9255228
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.

Refactor recommendation engine, improve dashboardUnverified

Julien Robert committed 2026-04-21T16:15:27+02:00 Browse files
9255228 parent: 6bb8ed5
modified internal/cluster/jaccard.go +363 -35
@@ -3,14 +3,38 @@ package cluster
33 import (
44 "context"
55 "database/sql"
6+ "fmt"
67 "log/slog"
78 "sync"
89 )
910
11+type Config struct {
12+ SimilarityThreshold float64
13+ FollowBoost float64
14+ LikesWeight float64
15+ TagsWeight float64
16+ DescriptionWeight float64
17+}
18+
19+func DefaultConfig() Config {
20+ return Config{
21+ SimilarityThreshold: 0.2,
22+ FollowBoost: 0.5,
23+ LikesWeight: 0.3,
24+ TagsWeight: 0.2,
25+ DescriptionWeight: 0.15,
26+ }
27+}
28+
1029 type Engine struct {
1130 db *sql.DB
1231 logger *slog.Logger
1332 mu sync.Mutex
33+ config Config
34+}
35+
36+func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
37+ return &Engine{db: db, logger: logger, config: DefaultConfig()}
1438 }
1539
1640 func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {
@@ -24,17 +48,17 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {
2448 return err
2549 }
2650
27- _, err = tx.ExecContext(ctx, `
51+ query := fmt.Sprintf(`
2852 INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score)
2953 SELECT targets.target, l.feed_url, l.article_url, SUM(targets.jaccard) AS score
3054 FROM (
3155 SELECT us.user_a AS target, us.user_b AS peer, us.jaccard
3256 FROM user_similarity us
33- WHERE us.jaccard > 0.2
57+ WHERE us.jaccard > %g
3458 UNION ALL
3559 SELECT us.user_b AS target, us.user_a AS peer, us.jaccard
3660 FROM user_similarity us
37- WHERE us.jaccard > 0.2
61+ WHERE us.jaccard > %g
3862 ) targets
3963 JOIN likes l ON l.author_did = targets.peer
4064 WHERE NOT EXISTS (
@@ -46,8 +70,9 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {
4670 GROUP BY targets.target, l.feed_url, l.article_url
4771 HAVING COUNT(*) > 0
4872 ORDER BY score DESC
49- `)
50- if err != nil {
73+ `, e.config.SimilarityThreshold, e.config.SimilarityThreshold)
74+
75+ if _, err := tx.ExecContext(ctx, query); err != nil {
5176 return err
5277 }
5378
@@ -70,10 +95,6 @@ func (e *Engine) ComputeForUser(ctx context.Context, userDID string) {
7095 }
7196 }
7297
73-func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
74- return &Engine{db: db, logger: logger}
75-}
76-
7798 func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
7899 tx, err := e.db.BeginTx(ctx, nil)
79100 if err != nil {
@@ -102,10 +123,104 @@ func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
102123 return err
103124 }
104125
126+ if err := e.computeDescriptionSimilarity(ctx, tx); err != nil {
127+ e.logger.Warn("description similarity failed", "error", err)
128+ }
129+
105130 e.logger.Info("feed similarity computed")
106131 return tx.Commit()
107132 }
108133
134+func (e *Engine) computeDescriptionSimilarity(ctx context.Context, tx *sql.Tx) error {
135+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _feed_words (feed_url TEXT, word TEXT)`); err != nil {
136+ return err
137+ }
138+ if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_words`); err != nil {
139+ return err
140+ }
141+
142+ _, err := tx.ExecContext(ctx, `
143+ INSERT INTO _feed_words (feed_url, word)
144+ WITH feed_tokens AS (
145+ SELECT feed_url, LOWER(TRIM(value)) AS word
146+ FROM feeds,
147+ json_each('["' || REPLACE(LOWER(COALESCE(description, '')), ' ', '","') || '"]')
148+ WHERE description IS NOT NULL AND description != ''
149+ )
150+ SELECT feed_url, word FROM feed_tokens
151+ WHERE LENGTH(word) > 3
152+ AND word NOT IN ('about','also','been','being','both','could','every','from','have','here',
153+ 'into','just','like','more','much','must','other','over','some','such','than','that',
154+ 'their','them','then','there','these','they','this','through','very','what','when',
155+ 'where','which','while','will','with','your','most','updated','latest','posts',
156+ 'news','blog','feed','reading','read','articles','article','weekly','daily',
157+ 'monthly','personal','thoughts','views','opinions','writing','write','written')
158+ `)
159+ if err != nil {
160+ return err
161+ }
162+
163+ descUpdate := fmt.Sprintf(`
164+ UPDATE feed_similarity SET
165+ jaccard = jaccard + %g * CAST(word_overlap.common AS REAL) / NULLIF(
166+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_a) +
167+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_b) -
168+ CAST(word_overlap.common AS REAL),
169+ 0
170+ )
171+ FROM (
172+ SELECT w1.feed_url AS feed_a, w2.feed_url AS feed_b, COUNT(DISTINCT w1.word) AS common
173+ FROM _feed_words w1
174+ JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
175+ GROUP BY w1.feed_url, w2.feed_url
176+ HAVING common > 1
177+ ) word_overlap
178+ WHERE feed_similarity.feed_a = word_overlap.feed_a
179+ AND feed_similarity.feed_b = word_overlap.feed_b
180+ `, e.config.DescriptionWeight)
181+
182+ if _, err := tx.ExecContext(ctx, descUpdate); err != nil {
183+ return err
184+ }
185+
186+ descInsert := fmt.Sprintf(`
187+ INSERT OR IGNORE INTO feed_similarity (feed_a, feed_b, jaccard)
188+ SELECT feed_a, feed_b, 0 FROM (
189+ SELECT w1.feed_url AS feed_a, w2.feed_url AS feed_b, COUNT(DISTINCT w1.word) AS common
190+ FROM _feed_words w1
191+ JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
192+ GROUP BY w1.feed_url, w2.feed_url
193+ HAVING common > 1
194+ )
195+ `)
196+ if _, err := tx.ExecContext(ctx, descInsert); err != nil {
197+ return err
198+ }
199+
200+ descBoost := fmt.Sprintf(`
201+ UPDATE feed_similarity SET
202+ jaccard = jaccard + %g * CAST(word_overlap.common AS REAL) / NULLIF(
203+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_a) +
204+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_b) -
205+ CAST(word_overlap.common AS REAL),
206+ 0
207+ )
208+ FROM (
209+ SELECT w1.feed_url AS feed_a, w2.feed_url AS feed_b, COUNT(DISTINCT w1.word) AS common
210+ FROM _feed_words w1
211+ JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
212+ GROUP BY w1.feed_url, w2.feed_url
213+ HAVING common > 1
214+ ) word_overlap
215+ WHERE feed_similarity.feed_a = word_overlap.feed_a
216+ AND feed_similarity.feed_b = word_overlap.feed_b
217+ AND feed_similarity.jaccard = 0
218+ `, e.config.DescriptionWeight)
219+
220+ _, err = tx.ExecContext(ctx, descBoost)
221+ return err
222+}
223+
109224 func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
110225 tx, err := e.db.BeginTx(ctx, nil)
111226 if err != nil {
@@ -137,20 +252,122 @@ func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
137252 return err
138253 }
139254
255+ likesUpdate := fmt.Sprintf(`
256+ UPDATE user_similarity SET
257+ jaccard = jaccard + %g * CAST(likes_overlap.common AS REAL) / NULLIF(
258+ (SELECT COUNT(*) FROM likes WHERE author_did = user_similarity.user_a) +
259+ (SELECT COUNT(*) FROM likes WHERE author_did = user_similarity.user_b) -
260+ CAST(likes_overlap.common AS REAL),
261+ 0
262+ ),
263+ common_likes = likes_overlap.common
264+ FROM (
265+ SELECT l1.author_did AS user_a, l2.author_did AS user_b, COUNT(*) AS common
266+ FROM likes l1
267+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
268+ AND l1.author_did < l2.author_did
269+ GROUP BY l1.author_did, l2.author_did
270+ HAVING common > 0
271+ ) likes_overlap
272+ WHERE user_similarity.user_a = likes_overlap.user_a
273+ AND user_similarity.user_b = likes_overlap.user_b
274+ `, e.config.LikesWeight)
275+
276+ if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
277+ return err
278+ }
279+
280+ likesInsert := fmt.Sprintf(`
281+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
282+ SELECT
283+ l1.author_did,
284+ l2.author_did,
285+ %g * CAST(COUNT(*) AS REAL) / NULLIF(
286+ (SELECT COUNT(*) FROM likes WHERE author_did = l1.author_did) +
287+ (SELECT COUNT(*) FROM likes WHERE author_did = l2.author_did) -
288+ CAST(COUNT(*) AS REAL),
289+ 0
290+ ),
291+ 0,
292+ COUNT(*)
293+ FROM likes l1
294+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
295+ AND l1.author_did < l2.author_did
296+ GROUP BY l1.author_did, l2.author_did
297+ HAVING COUNT(*) > 0
298+ ON CONFLICT(user_a, user_b) DO UPDATE SET
299+ jaccard = jaccard + excluded.jaccard,
300+ common_likes = excluded.common_likes
301+ `, e.config.LikesWeight)
302+
303+ if _, err := tx.ExecContext(ctx, likesInsert); err != nil {
304+ return err
305+ }
306+
307+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _tag_overlap (user_a TEXT, user_b TEXT, common INT)`); err != nil {
308+ return err
309+ }
310+ if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_overlap`); err != nil {
311+ return err
312+ }
313+
140314 _, err = tx.ExecContext(ctx, `
141- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)
315+ INSERT INTO _tag_overlap (user_a, user_b, common)
316+ WITH user_tags AS (
317+ SELECT author_did, TRIM(value) AS tag FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
318+ WHERE tags IS NOT NULL AND tags != ''
319+ )
320+ SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
321+ FROM user_tags t1
322+ JOIN user_tags t2 ON t1.tag = t2.tag AND t1.author_did < t2.author_did
323+ GROUP BY t1.author_did, t2.author_did
324+ HAVING COUNT(DISTINCT t1.tag) > 0
325+ `)
326+ if err != nil {
327+ return err
328+ }
329+
330+ _, err = tx.ExecContext(ctx, `
331+ INSERT OR IGNORE INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
332+ SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
333+ `)
334+ if err != nil {
335+ return err
336+ }
337+
338+ tagsUpdate := fmt.Sprintf(`
339+ UPDATE user_similarity SET
340+ jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
341+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did = user_similarity.user_a AND a.tags IS NOT NULL AND a.tags != '') +
342+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did = user_similarity.user_b AND a.tags IS NOT NULL AND a.tags != '') -
343+ CAST(_tag_overlap.common AS REAL),
344+ 0
345+ ),
346+ common_tags = _tag_overlap.common
347+ FROM _tag_overlap
348+ WHERE user_similarity.user_a = _tag_overlap.user_a
349+ AND user_similarity.user_b = _tag_overlap.user_b
350+ `, e.config.TagsWeight)
351+
352+ if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {
353+ return err
354+ }
355+
356+ followQuery := fmt.Sprintf(`
357+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
142358 SELECT
143359 MIN(f.user_did, f.target_did),
144360 MAX(f.user_did, f.target_did),
145- 0.5,
146- 0
361+ %g,
362+ 0, 0, 0
147363 FROM follows f
148364 WHERE f.user_did != f.target_did
149365 GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)
150366 ON CONFLICT(user_a, user_b) DO UPDATE SET
151- jaccard = jaccard + 0.5
152- `)
153- if err != nil {
367+ jaccard = jaccard + %g
368+ `, e.config.FollowBoost, e.config.FollowBoost)
369+
370+ if _, err := tx.ExecContext(ctx, followQuery); err != nil {
154371 return err
155372 }
156373
@@ -190,20 +407,128 @@ func (e *Engine) ComputeUserSimilarityForUser(ctx context.Context, userDID strin
190407 return err
191408 }
192409
410+ likesPerUser := fmt.Sprintf(`
411+ UPDATE user_similarity SET
412+ jaccard = jaccard + %g * CAST(likes_overlap.common AS REAL) / NULLIF(
413+ (SELECT COUNT(*) FROM likes WHERE author_did = ?) +
414+ (SELECT COUNT(*) FROM likes WHERE author_did =
415+ CASE WHEN user_similarity.user_a = ? THEN user_similarity.user_b ELSE user_similarity.user_a END
416+ ) - CAST(likes_overlap.common AS REAL),
417+ 0
418+ ),
419+ common_likes = likes_overlap.common
420+ FROM (
421+ SELECT l2.author_did AS peer, COUNT(*) AS common
422+ FROM likes l1
423+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
424+ AND l2.author_did != ?
425+ WHERE l1.author_did = ?
426+ GROUP BY l2.author_did
427+ HAVING common > 0
428+ ) likes_overlap
429+ WHERE user_similarity.user_a = likes_overlap.peer
430+ OR user_similarity.user_b = likes_overlap.peer
431+ `, e.config.LikesWeight)
432+
433+ if _, err := tx.ExecContext(ctx, likesPerUser, userDID, userDID, userDID, userDID); err != nil {
434+ return err
435+ }
436+
437+ likesInsert := fmt.Sprintf(`
438+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
439+ SELECT
440+ MIN(?, l2.author_did),
441+ MAX(?, l2.author_did),
442+ %g * CAST(COUNT(*) AS REAL) / NULLIF(
443+ (SELECT COUNT(*) FROM likes WHERE author_did = ?) +
444+ (SELECT COUNT(*) FROM likes WHERE author_did = l2.author_did) -
445+ CAST(COUNT(*) AS REAL),
446+ 0
447+ ),
448+ 0,
449+ COUNT(*)
450+ FROM likes l1
451+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
452+ AND l2.author_did != ?
453+ WHERE l1.author_did = ?
454+ GROUP BY l2.author_did
455+ HAVING COUNT(*) > 0
456+ ON CONFLICT(user_a, user_b) DO UPDATE SET
457+ jaccard = jaccard + excluded.jaccard,
458+ common_likes = excluded.common_likes
459+ `, e.config.LikesWeight)
460+
461+ if _, err := tx.ExecContext(ctx, likesInsert, userDID, userDID, userDID, userDID, userDID); err != nil {
462+ return err
463+ }
464+
465+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _user_tag_overlap (peer TEXT, common INT)`); err != nil {
466+ return err
467+ }
468+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_tag_overlap`); err != nil {
469+ return err
470+ }
471+
472+ _, err = tx.ExecContext(ctx, `
473+ INSERT INTO _user_tag_overlap (peer, common)
474+ WITH user_tags AS (
475+ SELECT author_did, TRIM(value) AS tag FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
476+ WHERE tags IS NOT NULL AND tags != ''
477+ )
478+ SELECT t2.author_did, COUNT(DISTINCT t1.tag)
479+ FROM user_tags t1
480+ JOIN user_tags t2 ON t1.tag = t2.tag AND t2.author_did != ?
481+ WHERE t1.author_did = ?
482+ GROUP BY t2.author_did
483+ HAVING COUNT(DISTINCT t1.tag) > 0
484+ `, userDID, userDID)
485+ if err != nil {
486+ return err
487+ }
488+
193489 _, err = tx.ExecContext(ctx, `
194- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)
490+ INSERT OR IGNORE INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
491+ SELECT MIN(?, peer), MAX(?, peer), 0, 0, 0 FROM _user_tag_overlap
492+ `, userDID, userDID)
493+ if err != nil {
494+ return err
495+ }
496+
497+ tagsPerUser := fmt.Sprintf(`
498+ UPDATE user_similarity SET
499+ jaccard = jaccard + %g * CAST(_user_tag_overlap.common AS REAL) / NULLIF(
500+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did = ? AND a.tags IS NOT NULL AND a.tags != '') +
501+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did =
502+ CASE WHEN user_similarity.user_a = ? THEN user_similarity.user_b ELSE user_similarity.user_a END
503+ AND a.tags IS NOT NULL AND a.tags != '') -
504+ CAST(_user_tag_overlap.common AS REAL),
505+ 0
506+ ),
507+ common_tags = _user_tag_overlap.common
508+ FROM _user_tag_overlap
509+ WHERE user_similarity.user_a = _user_tag_overlap.peer
510+ OR user_similarity.user_b = _user_tag_overlap.peer
511+ `, e.config.TagsWeight)
512+
513+ if _, err := tx.ExecContext(ctx, tagsPerUser, userDID, userDID); err != nil {
514+ return err
515+ }
516+
517+ followQuery := fmt.Sprintf(`
518+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
195519 SELECT
196520 MIN(?, f.target_did),
197521 MAX(?, f.target_did),
198- 0.5,
199- 0
522+ %g,
523+ 0, 0, 0
200524 FROM follows f
201525 WHERE f.user_did = ? AND f.target_did != ?
202526 GROUP BY MIN(?, f.target_did), MAX(?, f.target_did)
203527 ON CONFLICT(user_a, user_b) DO UPDATE SET
204- jaccard = jaccard + 0.5
205- `, userDID, userDID, userDID, userDID, userDID, userDID)
206- if err != nil {
528+ jaccard = jaccard + %g
529+ `, e.config.FollowBoost, e.config.FollowBoost)
530+
531+ if _, err := tx.ExecContext(ctx, followQuery, userDID, userDID, userDID, userDID, userDID, userDID); err != nil {
207532 return err
208533 }
209534
@@ -222,7 +547,7 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri
222547 return err
223548 }
224549
225- _, err = tx.ExecContext(ctx, `
550+ recQuery := fmt.Sprintf(`
226551 INSERT INTO user_feed_recommendations (user_did, feed_url, score)
227552 SELECT ?, s.feed_url, SUM(us.jaccard) AS score
228553 FROM user_similarity us
@@ -231,12 +556,13 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri
231556 ELSE us.user_a
232557 END
233558 WHERE (us.user_a = ? OR us.user_b = ?)
234- AND us.jaccard > 0.2
559+ AND us.jaccard > %g
235560 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
236561 GROUP BY s.feed_url
237562 ORDER BY score DESC
238- `, userDID, userDID, userDID, userDID, userDID)
239- if err != nil {
563+ `, e.config.SimilarityThreshold)
564+
565+ if _, err := tx.ExecContext(ctx, recQuery, userDID, userDID, userDID, userDID, userDID); err != nil {
240566 return err
241567 }
242568
@@ -263,15 +589,15 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD
263589 return err
264590 }
265591
266- _, err = tx.ExecContext(ctx, `
592+ artQuery := fmt.Sprintf(`
267593 INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score)
268594 SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score
269595 FROM (
270596 SELECT us.user_b AS peer, us.jaccard
271- FROM user_similarity us WHERE us.user_a = ? AND us.jaccard > 0.2
597+ FROM user_similarity us WHERE us.user_a = ? AND us.jaccard > %g
272598 UNION ALL
273599 SELECT us.user_a AS peer, us.jaccard
274- FROM user_similarity us WHERE us.user_b = ? AND us.jaccard > 0.2
600+ FROM user_similarity us WHERE us.user_b = ? AND us.jaccard > %g
275601 ) us
276602 JOIN likes l ON l.author_did = us.peer
277603 WHERE NOT EXISTS (
@@ -283,8 +609,9 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD
283609 GROUP BY l.feed_url, l.article_url
284610 HAVING COUNT(*) > 0
285611 ORDER BY score DESC
286- `, userDID, userDID, userDID, userDID, userDID)
287- if err != nil {
612+ `, e.config.SimilarityThreshold, e.config.SimilarityThreshold)
613+
614+ if _, err := tx.ExecContext(ctx, artQuery, userDID, userDID, userDID, userDID, userDID); err != nil {
288615 return err
289616 }
290617
@@ -302,14 +629,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error {
302629 return err
303630 }
304631
305- _, err = tx.ExecContext(ctx, `
632+ recQuery := fmt.Sprintf(`
306633 INSERT INTO user_feed_recommendations (user_did, feed_url, score)
307634 SELECT target, feed_url, SUM(jaccard) AS score
308635 FROM (
309636 SELECT us.user_a AS target, s.feed_url, us.jaccard
310637 FROM user_similarity us
311638 JOIN subscriptions s ON s.user_did = us.user_b
312- WHERE us.jaccard > 0.2
639+ WHERE us.jaccard > %g
313640 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a)
314641
315642 UNION ALL
@@ -317,13 +644,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error {
317644 SELECT us.user_b AS target, s.feed_url, us.jaccard
318645 FROM user_similarity us
319646 JOIN subscriptions s ON s.user_did = us.user_a
320- WHERE us.jaccard > 0.2
647+ WHERE us.jaccard > %g
321648 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b)
322649 )
323650 GROUP BY target, feed_url
324651 ORDER BY score DESC
325- `)
326- if err != nil {
652+ `, e.config.SimilarityThreshold, e.config.SimilarityThreshold)
653+
654+ if _, err := tx.ExecContext(ctx, recQuery); err != nil {
327655 return err
328656 }
329657
@@ -3,14 +3,38 @@ package cluster
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6+ "fmt"
6 "log/slog"7 "log/slog"
7 "sync"8 "sync"
8 )9 )
9 10
11+type Config struct {
12+ SimilarityThreshold float64
13+ FollowBoost float64
14+ LikesWeight float64
15+ TagsWeight float64
16+ DescriptionWeight float64
17+}
18+
19+func DefaultConfig() Config {
20+ return Config{
21+ SimilarityThreshold: 0.2,
22+ FollowBoost: 0.5,
23+ LikesWeight: 0.3,
24+ TagsWeight: 0.2,
25+ DescriptionWeight: 0.15,
26+ }
27+}
28+
10 type Engine struct {29 type Engine struct {
11 db *sql.DB30 db *sql.DB
12 logger *slog.Logger31 logger *slog.Logger
13 mu sync.Mutex32 mu sync.Mutex
33+ config Config
34+}
35+
36+func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
37+ return &Engine{db: db, logger: logger, config: DefaultConfig()}
14 }38 }
15 39
16 func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {40 func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {
@@ -24,17 +48,17 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {
24 return err48 return err
25 }49 }
26 50
27- _, err = tx.ExecContext(ctx, `51+ query := fmt.Sprintf(`
28 INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score)52 INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score)
29 SELECT targets.target, l.feed_url, l.article_url, SUM(targets.jaccard) AS score53 SELECT targets.target, l.feed_url, l.article_url, SUM(targets.jaccard) AS score
30 FROM (54 FROM (
31 SELECT us.user_a AS target, us.user_b AS peer, us.jaccard55 SELECT us.user_a AS target, us.user_b AS peer, us.jaccard
32 FROM user_similarity us56 FROM user_similarity us
33- WHERE us.jaccard > 0.257+ WHERE us.jaccard > %g
34 UNION ALL58 UNION ALL
35 SELECT us.user_b AS target, us.user_a AS peer, us.jaccard59 SELECT us.user_b AS target, us.user_a AS peer, us.jaccard
36 FROM user_similarity us60 FROM user_similarity us
37- WHERE us.jaccard > 0.261+ WHERE us.jaccard > %g
38 ) targets62 ) targets
39 JOIN likes l ON l.author_did = targets.peer63 JOIN likes l ON l.author_did = targets.peer
40 WHERE NOT EXISTS (64 WHERE NOT EXISTS (
@@ -46,8 +70,9 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error {
46 GROUP BY targets.target, l.feed_url, l.article_url70 GROUP BY targets.target, l.feed_url, l.article_url
47 HAVING COUNT(*) > 071 HAVING COUNT(*) > 0
48 ORDER BY score DESC72 ORDER BY score DESC
49- `)73+ `, e.config.SimilarityThreshold, e.config.SimilarityThreshold)
50- if err != nil {74+
75+ if _, err := tx.ExecContext(ctx, query); err != nil {
51 return err76 return err
52 }77 }
53 78
@@ -70,10 +95,6 @@ func (e *Engine) ComputeForUser(ctx context.Context, userDID string) {
70 }95 }
71 }96 }
72 97
73-func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
74- return &Engine{db: db, logger: logger}
75-}
76-
77 func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {98 func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
78 tx, err := e.db.BeginTx(ctx, nil)99 tx, err := e.db.BeginTx(ctx, nil)
79 if err != nil {100 if err != nil {
@@ -102,10 +123,104 @@ func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
102 return err123 return err
103 }124 }
104 125
126+ if err := e.computeDescriptionSimilarity(ctx, tx); err != nil {
127+ e.logger.Warn("description similarity failed", "error", err)
128+ }
129+
105 e.logger.Info("feed similarity computed")130 e.logger.Info("feed similarity computed")
106 return tx.Commit()131 return tx.Commit()
107 }132 }
108 133
134+func (e *Engine) computeDescriptionSimilarity(ctx context.Context, tx *sql.Tx) error {
135+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _feed_words (feed_url TEXT, word TEXT)`); err != nil {
136+ return err
137+ }
138+ if _, err := tx.ExecContext(ctx, `DELETE FROM _feed_words`); err != nil {
139+ return err
140+ }
141+
142+ _, err := tx.ExecContext(ctx, `
143+ INSERT INTO _feed_words (feed_url, word)
144+ WITH feed_tokens AS (
145+ SELECT feed_url, LOWER(TRIM(value)) AS word
146+ FROM feeds,
147+ json_each('["' || REPLACE(LOWER(COALESCE(description, '')), ' ', '","') || '"]')
148+ WHERE description IS NOT NULL AND description != ''
149+ )
150+ SELECT feed_url, word FROM feed_tokens
151+ WHERE LENGTH(word) > 3
152+ AND word NOT IN ('about','also','been','being','both','could','every','from','have','here',
153+ 'into','just','like','more','much','must','other','over','some','such','than','that',
154+ 'their','them','then','there','these','they','this','through','very','what','when',
155+ 'where','which','while','will','with','your','most','updated','latest','posts',
156+ 'news','blog','feed','reading','read','articles','article','weekly','daily',
157+ 'monthly','personal','thoughts','views','opinions','writing','write','written')
158+ `)
159+ if err != nil {
160+ return err
161+ }
162+
163+ descUpdate := fmt.Sprintf(`
164+ UPDATE feed_similarity SET
165+ jaccard = jaccard + %g * CAST(word_overlap.common AS REAL) / NULLIF(
166+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_a) +
167+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_b) -
168+ CAST(word_overlap.common AS REAL),
169+ 0
170+ )
171+ FROM (
172+ SELECT w1.feed_url AS feed_a, w2.feed_url AS feed_b, COUNT(DISTINCT w1.word) AS common
173+ FROM _feed_words w1
174+ JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
175+ GROUP BY w1.feed_url, w2.feed_url
176+ HAVING common > 1
177+ ) word_overlap
178+ WHERE feed_similarity.feed_a = word_overlap.feed_a
179+ AND feed_similarity.feed_b = word_overlap.feed_b
180+ `, e.config.DescriptionWeight)
181+
182+ if _, err := tx.ExecContext(ctx, descUpdate); err != nil {
183+ return err
184+ }
185+
186+ descInsert := fmt.Sprintf(`
187+ INSERT OR IGNORE INTO feed_similarity (feed_a, feed_b, jaccard)
188+ SELECT feed_a, feed_b, 0 FROM (
189+ SELECT w1.feed_url AS feed_a, w2.feed_url AS feed_b, COUNT(DISTINCT w1.word) AS common
190+ FROM _feed_words w1
191+ JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
192+ GROUP BY w1.feed_url, w2.feed_url
193+ HAVING common > 1
194+ )
195+ `)
196+ if _, err := tx.ExecContext(ctx, descInsert); err != nil {
197+ return err
198+ }
199+
200+ descBoost := fmt.Sprintf(`
201+ UPDATE feed_similarity SET
202+ jaccard = jaccard + %g * CAST(word_overlap.common AS REAL) / NULLIF(
203+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_a) +
204+ (SELECT COUNT(DISTINCT word) FROM _feed_words WHERE feed_url = feed_similarity.feed_b) -
205+ CAST(word_overlap.common AS REAL),
206+ 0
207+ )
208+ FROM (
209+ SELECT w1.feed_url AS feed_a, w2.feed_url AS feed_b, COUNT(DISTINCT w1.word) AS common
210+ FROM _feed_words w1
211+ JOIN _feed_words w2 ON w1.word = w2.word AND w1.feed_url < w2.feed_url
212+ GROUP BY w1.feed_url, w2.feed_url
213+ HAVING common > 1
214+ ) word_overlap
215+ WHERE feed_similarity.feed_a = word_overlap.feed_a
216+ AND feed_similarity.feed_b = word_overlap.feed_b
217+ AND feed_similarity.jaccard = 0
218+ `, e.config.DescriptionWeight)
219+
220+ _, err = tx.ExecContext(ctx, descBoost)
221+ return err
222+}
223+
109 func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {224 func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
110 tx, err := e.db.BeginTx(ctx, nil)225 tx, err := e.db.BeginTx(ctx, nil)
111 if err != nil {226 if err != nil {
@@ -137,20 +252,122 @@ func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
137 return err252 return err
138 }253 }
139 254
255+ likesUpdate := fmt.Sprintf(`
256+ UPDATE user_similarity SET
257+ jaccard = jaccard + %g * CAST(likes_overlap.common AS REAL) / NULLIF(
258+ (SELECT COUNT(*) FROM likes WHERE author_did = user_similarity.user_a) +
259+ (SELECT COUNT(*) FROM likes WHERE author_did = user_similarity.user_b) -
260+ CAST(likes_overlap.common AS REAL),
261+ 0
262+ ),
263+ common_likes = likes_overlap.common
264+ FROM (
265+ SELECT l1.author_did AS user_a, l2.author_did AS user_b, COUNT(*) AS common
266+ FROM likes l1
267+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
268+ AND l1.author_did < l2.author_did
269+ GROUP BY l1.author_did, l2.author_did
270+ HAVING common > 0
271+ ) likes_overlap
272+ WHERE user_similarity.user_a = likes_overlap.user_a
273+ AND user_similarity.user_b = likes_overlap.user_b
274+ `, e.config.LikesWeight)
275+
276+ if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
277+ return err
278+ }
279+
280+ likesInsert := fmt.Sprintf(`
281+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
282+ SELECT
283+ l1.author_did,
284+ l2.author_did,
285+ %g * CAST(COUNT(*) AS REAL) / NULLIF(
286+ (SELECT COUNT(*) FROM likes WHERE author_did = l1.author_did) +
287+ (SELECT COUNT(*) FROM likes WHERE author_did = l2.author_did) -
288+ CAST(COUNT(*) AS REAL),
289+ 0
290+ ),
291+ 0,
292+ COUNT(*)
293+ FROM likes l1
294+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
295+ AND l1.author_did < l2.author_did
296+ GROUP BY l1.author_did, l2.author_did
297+ HAVING COUNT(*) > 0
298+ ON CONFLICT(user_a, user_b) DO UPDATE SET
299+ jaccard = jaccard + excluded.jaccard,
300+ common_likes = excluded.common_likes
301+ `, e.config.LikesWeight)
302+
303+ if _, err := tx.ExecContext(ctx, likesInsert); err != nil {
304+ return err
305+ }
306+
307+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _tag_overlap (user_a TEXT, user_b TEXT, common INT)`); err != nil {
308+ return err
309+ }
310+ if _, err := tx.ExecContext(ctx, `DELETE FROM _tag_overlap`); err != nil {
311+ return err
312+ }
313+
140 _, err = tx.ExecContext(ctx, `314 _, err = tx.ExecContext(ctx, `
141- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)315+ INSERT INTO _tag_overlap (user_a, user_b, common)
316+ WITH user_tags AS (
317+ SELECT author_did, TRIM(value) AS tag FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
318+ WHERE tags IS NOT NULL AND tags != ''
319+ )
320+ SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
321+ FROM user_tags t1
322+ JOIN user_tags t2 ON t1.tag = t2.tag AND t1.author_did < t2.author_did
323+ GROUP BY t1.author_did, t2.author_did
324+ HAVING COUNT(DISTINCT t1.tag) > 0
325+ `)
326+ if err != nil {
327+ return err
328+ }
329+
330+ _, err = tx.ExecContext(ctx, `
331+ INSERT OR IGNORE INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
332+ SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
333+ `)
334+ if err != nil {
335+ return err
336+ }
337+
338+ tagsUpdate := fmt.Sprintf(`
339+ UPDATE user_similarity SET
340+ jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
341+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did = user_similarity.user_a AND a.tags IS NOT NULL AND a.tags != '') +
342+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did = user_similarity.user_b AND a.tags IS NOT NULL AND a.tags != '') -
343+ CAST(_tag_overlap.common AS REAL),
344+ 0
345+ ),
346+ common_tags = _tag_overlap.common
347+ FROM _tag_overlap
348+ WHERE user_similarity.user_a = _tag_overlap.user_a
349+ AND user_similarity.user_b = _tag_overlap.user_b
350+ `, e.config.TagsWeight)
351+
352+ if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {
353+ return err
354+ }
355+
356+ followQuery := fmt.Sprintf(`
357+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
142 SELECT358 SELECT
143 MIN(f.user_did, f.target_did),359 MIN(f.user_did, f.target_did),
144 MAX(f.user_did, f.target_did),360 MAX(f.user_did, f.target_did),
145- 0.5,361+ %g,
146- 0362+ 0, 0, 0
147 FROM follows f363 FROM follows f
148 WHERE f.user_did != f.target_did364 WHERE f.user_did != f.target_did
149 GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)365 GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)
150 ON CONFLICT(user_a, user_b) DO UPDATE SET366 ON CONFLICT(user_a, user_b) DO UPDATE SET
151- jaccard = jaccard + 0.5367+ jaccard = jaccard + %g
152- `)368+ `, e.config.FollowBoost, e.config.FollowBoost)
153- if err != nil {369+
370+ if _, err := tx.ExecContext(ctx, followQuery); err != nil {
154 return err371 return err
155 }372 }
156 373
@@ -190,20 +407,128 @@ func (e *Engine) ComputeUserSimilarityForUser(ctx context.Context, userDID strin
190 return err407 return err
191 }408 }
192 409
410+ likesPerUser := fmt.Sprintf(`
411+ UPDATE user_similarity SET
412+ jaccard = jaccard + %g * CAST(likes_overlap.common AS REAL) / NULLIF(
413+ (SELECT COUNT(*) FROM likes WHERE author_did = ?) +
414+ (SELECT COUNT(*) FROM likes WHERE author_did =
415+ CASE WHEN user_similarity.user_a = ? THEN user_similarity.user_b ELSE user_similarity.user_a END
416+ ) - CAST(likes_overlap.common AS REAL),
417+ 0
418+ ),
419+ common_likes = likes_overlap.common
420+ FROM (
421+ SELECT l2.author_did AS peer, COUNT(*) AS common
422+ FROM likes l1
423+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
424+ AND l2.author_did != ?
425+ WHERE l1.author_did = ?
426+ GROUP BY l2.author_did
427+ HAVING common > 0
428+ ) likes_overlap
429+ WHERE user_similarity.user_a = likes_overlap.peer
430+ OR user_similarity.user_b = likes_overlap.peer
431+ `, e.config.LikesWeight)
432+
433+ if _, err := tx.ExecContext(ctx, likesPerUser, userDID, userDID, userDID, userDID); err != nil {
434+ return err
435+ }
436+
437+ likesInsert := fmt.Sprintf(`
438+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
439+ SELECT
440+ MIN(?, l2.author_did),
441+ MAX(?, l2.author_did),
442+ %g * CAST(COUNT(*) AS REAL) / NULLIF(
443+ (SELECT COUNT(*) FROM likes WHERE author_did = ?) +
444+ (SELECT COUNT(*) FROM likes WHERE author_did = l2.author_did) -
445+ CAST(COUNT(*) AS REAL),
446+ 0
447+ ),
448+ 0,
449+ COUNT(*)
450+ FROM likes l1
451+ JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
452+ AND l2.author_did != ?
453+ WHERE l1.author_did = ?
454+ GROUP BY l2.author_did
455+ HAVING COUNT(*) > 0
456+ ON CONFLICT(user_a, user_b) DO UPDATE SET
457+ jaccard = jaccard + excluded.jaccard,
458+ common_likes = excluded.common_likes
459+ `, e.config.LikesWeight)
460+
461+ if _, err := tx.ExecContext(ctx, likesInsert, userDID, userDID, userDID, userDID, userDID); err != nil {
462+ return err
463+ }
464+
465+ if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _user_tag_overlap (peer TEXT, common INT)`); err != nil {
466+ return err
467+ }
468+ if _, err := tx.ExecContext(ctx, `DELETE FROM _user_tag_overlap`); err != nil {
469+ return err
470+ }
471+
472+ _, err = tx.ExecContext(ctx, `
473+ INSERT INTO _user_tag_overlap (peer, common)
474+ WITH user_tags AS (
475+ SELECT author_did, TRIM(value) AS tag FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
476+ WHERE tags IS NOT NULL AND tags != ''
477+ )
478+ SELECT t2.author_did, COUNT(DISTINCT t1.tag)
479+ FROM user_tags t1
480+ JOIN user_tags t2 ON t1.tag = t2.tag AND t2.author_did != ?
481+ WHERE t1.author_did = ?
482+ GROUP BY t2.author_did
483+ HAVING COUNT(DISTINCT t1.tag) > 0
484+ `, userDID, userDID)
485+ if err != nil {
486+ return err
487+ }
488+
193 _, err = tx.ExecContext(ctx, `489 _, err = tx.ExecContext(ctx, `
194- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)490+ INSERT OR IGNORE INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
491+ SELECT MIN(?, peer), MAX(?, peer), 0, 0, 0 FROM _user_tag_overlap
492+ `, userDID, userDID)
493+ if err != nil {
494+ return err
495+ }
496+
497+ tagsPerUser := fmt.Sprintf(`
498+ UPDATE user_similarity SET
499+ jaccard = jaccard + %g * CAST(_user_tag_overlap.common AS REAL) / NULLIF(
500+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did = ? AND a.tags IS NOT NULL AND a.tags != '') +
501+ (SELECT COUNT(DISTINCT TRIM(value)) FROM annotations a, json_each('["' || REPLACE(a.tags, ',', '","') || '"]') WHERE a.author_did =
502+ CASE WHEN user_similarity.user_a = ? THEN user_similarity.user_b ELSE user_similarity.user_a END
503+ AND a.tags IS NOT NULL AND a.tags != '') -
504+ CAST(_user_tag_overlap.common AS REAL),
505+ 0
506+ ),
507+ common_tags = _user_tag_overlap.common
508+ FROM _user_tag_overlap
509+ WHERE user_similarity.user_a = _user_tag_overlap.peer
510+ OR user_similarity.user_b = _user_tag_overlap.peer
511+ `, e.config.TagsWeight)
512+
513+ if _, err := tx.ExecContext(ctx, tagsPerUser, userDID, userDID); err != nil {
514+ return err
515+ }
516+
517+ followQuery := fmt.Sprintf(`
518+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
195 SELECT519 SELECT
196 MIN(?, f.target_did),520 MIN(?, f.target_did),
197 MAX(?, f.target_did),521 MAX(?, f.target_did),
198- 0.5,522+ %g,
199- 0523+ 0, 0, 0
200 FROM follows f524 FROM follows f
201 WHERE f.user_did = ? AND f.target_did != ?525 WHERE f.user_did = ? AND f.target_did != ?
202 GROUP BY MIN(?, f.target_did), MAX(?, f.target_did)526 GROUP BY MIN(?, f.target_did), MAX(?, f.target_did)
203 ON CONFLICT(user_a, user_b) DO UPDATE SET527 ON CONFLICT(user_a, user_b) DO UPDATE SET
204- jaccard = jaccard + 0.5528+ jaccard = jaccard + %g
205- `, userDID, userDID, userDID, userDID, userDID, userDID)529+ `, e.config.FollowBoost, e.config.FollowBoost)
206- if err != nil {530+
531+ if _, err := tx.ExecContext(ctx, followQuery, userDID, userDID, userDID, userDID, userDID, userDID); err != nil {
207 return err532 return err
208 }533 }
209 534
@@ -222,7 +547,7 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri
222 return err547 return err
223 }548 }
224 549
225- _, err = tx.ExecContext(ctx, `550+ recQuery := fmt.Sprintf(`
226 INSERT INTO user_feed_recommendations (user_did, feed_url, score)551 INSERT INTO user_feed_recommendations (user_did, feed_url, score)
227 SELECT ?, s.feed_url, SUM(us.jaccard) AS score552 SELECT ?, s.feed_url, SUM(us.jaccard) AS score
228 FROM user_similarity us553 FROM user_similarity us
@@ -231,12 +556,13 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri
231 ELSE us.user_a556 ELSE us.user_a
232 END557 END
233 WHERE (us.user_a = ? OR us.user_b = ?)558 WHERE (us.user_a = ? OR us.user_b = ?)
234- AND us.jaccard > 0.2559+ AND us.jaccard > %g
235 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)560 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
236 GROUP BY s.feed_url561 GROUP BY s.feed_url
237 ORDER BY score DESC562 ORDER BY score DESC
238- `, userDID, userDID, userDID, userDID, userDID)563+ `, e.config.SimilarityThreshold)
239- if err != nil {564+
565+ if _, err := tx.ExecContext(ctx, recQuery, userDID, userDID, userDID, userDID, userDID); err != nil {
240 return err566 return err
241 }567 }
242 568
@@ -263,15 +589,15 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD
263 return err589 return err
264 }590 }
265 591
266- _, err = tx.ExecContext(ctx, `592+ artQuery := fmt.Sprintf(`
267 INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score)593 INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score)
268 SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score594 SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score
269 FROM (595 FROM (
270 SELECT us.user_b AS peer, us.jaccard596 SELECT us.user_b AS peer, us.jaccard
271- FROM user_similarity us WHERE us.user_a = ? AND us.jaccard > 0.2597+ FROM user_similarity us WHERE us.user_a = ? AND us.jaccard > %g
272 UNION ALL598 UNION ALL
273 SELECT us.user_a AS peer, us.jaccard599 SELECT us.user_a AS peer, us.jaccard
274- FROM user_similarity us WHERE us.user_b = ? AND us.jaccard > 0.2600+ FROM user_similarity us WHERE us.user_b = ? AND us.jaccard > %g
275 ) us601 ) us
276 JOIN likes l ON l.author_did = us.peer602 JOIN likes l ON l.author_did = us.peer
277 WHERE NOT EXISTS (603 WHERE NOT EXISTS (
@@ -283,8 +609,9 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD
283 GROUP BY l.feed_url, l.article_url609 GROUP BY l.feed_url, l.article_url
284 HAVING COUNT(*) > 0610 HAVING COUNT(*) > 0
285 ORDER BY score DESC611 ORDER BY score DESC
286- `, userDID, userDID, userDID, userDID, userDID)612+ `, e.config.SimilarityThreshold, e.config.SimilarityThreshold)
287- if err != nil {613+
614+ if _, err := tx.ExecContext(ctx, artQuery, userDID, userDID, userDID, userDID, userDID); err != nil {
288 return err615 return err
289 }616 }
290 617
@@ -302,14 +629,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error {
302 return err629 return err
303 }630 }
304 631
305- _, err = tx.ExecContext(ctx, `632+ recQuery := fmt.Sprintf(`
306 INSERT INTO user_feed_recommendations (user_did, feed_url, score)633 INSERT INTO user_feed_recommendations (user_did, feed_url, score)
307 SELECT target, feed_url, SUM(jaccard) AS score634 SELECT target, feed_url, SUM(jaccard) AS score
308 FROM (635 FROM (
309 SELECT us.user_a AS target, s.feed_url, us.jaccard636 SELECT us.user_a AS target, s.feed_url, us.jaccard
310 FROM user_similarity us637 FROM user_similarity us
311 JOIN subscriptions s ON s.user_did = us.user_b638 JOIN subscriptions s ON s.user_did = us.user_b
312- WHERE us.jaccard > 0.2639+ WHERE us.jaccard > %g
313 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a)640 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a)
314 641
315 UNION ALL642 UNION ALL
@@ -317,13 +644,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error {
317 SELECT us.user_b AS target, s.feed_url, us.jaccard644 SELECT us.user_b AS target, s.feed_url, us.jaccard
318 FROM user_similarity us645 FROM user_similarity us
319 JOIN subscriptions s ON s.user_did = us.user_a646 JOIN subscriptions s ON s.user_did = us.user_a
320- WHERE us.jaccard > 0.2647+ WHERE us.jaccard > %g
321 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b)648 AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b)
322 )649 )
323 GROUP BY target, feed_url650 GROUP BY target, feed_url
324 ORDER BY score DESC651 ORDER BY score DESC
325- `)652+ `, e.config.SimilarityThreshold, e.config.SimilarityThreshold)
326- if err != nil {653+
654+ if _, err := tx.ExecContext(ctx, recQuery); err != nil {
327 return err655 return err
328 }656 }
329 657
modified internal/cluster/jaccard_test.go +101 -3
@@ -110,7 +110,7 @@ func TestComputeRecommendations_GeneratesFeedRecsForNewUser(t *testing.T) {
110110
111111 var found bool
112112 for _, r := range recs {
113- if r["feed_url"] == "https://a.com/feed" || r["feed_url"] == "https://b.com/feed" {
113+ if r.FeedURL == "https://a.com/feed" || r.FeedURL == "https://b.com/feed" {
114114 found = true
115115 }
116116 }
@@ -136,7 +136,7 @@ func TestComputeRecommendations_NoSelfRecommendations(t *testing.T) {
136136 "https://c.com/feed": true,
137137 }
138138 for _, r := range recs {
139- assert.Assert(t, !subscribedFeeds[r["feed_url"].(string)],
139+ assert.Assert(t, !subscribedFeeds[r.FeedURL],
140140 "should not recommend a feed the user already subscribes to")
141141 }
142142 }
@@ -257,7 +257,7 @@ func TestComputeRecommendationsForUser(t *testing.T) {
257257
258258 var found bool
259259 for _, r := range recs {
260- if r["feed_url"] == "https://a.com/feed" || r["feed_url"] == "https://b.com/feed" {
260+ if r.FeedURL == "https://a.com/feed" || r.FeedURL == "https://b.com/feed" {
261261 found = true
262262 }
263263 }
@@ -305,3 +305,101 @@ func TestComputeRecommendationsForUser_MatchesFullCompute(t *testing.T) {
305305 assert.Equal(t, score, incrScore, "score mismatch for %s", url)
306306 }
307307 }
308+
309+func TestLikesBasedSimilarity(t *testing.T) {
310+ ctx := context.Background()
311+ database := setupClusterTestDB(t)
312+ seedClusterData(t, ctx, database)
313+
314+ _, err := database.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
315+ "https://a.com/feed", "art1", "Article 1", "https://a.com/art1")
316+ assert.NilError(t, err)
317+ _, err = database.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
318+ "https://a.com/feed", "art2", "Article 2", "https://a.com/art2")
319+ assert.NilError(t, err)
320+
321+ _, err = database.ExecContext(ctx, `INSERT INTO likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
322+ "at://alice/like/1", "did:test:alice", "https://a.com/feed", "https://a.com/art1")
323+ assert.NilError(t, err)
324+ _, err = database.ExecContext(ctx, `INSERT INTO likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
325+ "at://alice/like/2", "did:test:alice", "https://a.com/feed", "https://a.com/art2")
326+ assert.NilError(t, err)
327+ _, err = database.ExecContext(ctx, `INSERT INTO likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
328+ "at://carol/like/1", "did:test:carol", "https://a.com/feed", "https://a.com/art1")
329+ assert.NilError(t, err)
330+
331+ engine := NewEngine(database.DB, slog.Default())
332+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
333+
334+ var jaccard float64
335+ var commonLikes int
336+ assert.NilError(t, database.QueryRowContext(ctx,
337+ `SELECT jaccard, common_likes FROM user_similarity WHERE user_a = ? AND user_b = ?`,
338+ "did:test:alice", "did:test:carol").Scan(&jaccard, &commonLikes))
339+ assert.Equal(t, commonLikes, 1, "alice and carol share 1 liked article")
340+ assert.Assert(t, jaccard > 0, "likes should contribute to similarity, got %f", jaccard)
341+}
342+
343+func TestTagsBasedSimilarity(t *testing.T) {
344+ ctx := context.Background()
345+ database := setupClusterTestDB(t)
346+ seedClusterData(t, ctx, database)
347+
348+ _, err := database.ExecContext(ctx, `INSERT INTO annotations (uri, author_did, feed_url, article_url, tags, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
349+ "at://alice/ann/1", "did:test:alice", "https://a.com/feed", "https://a.com/art1", "go,programming")
350+ assert.NilError(t, err)
351+ _, err = database.ExecContext(ctx, `INSERT INTO annotations (uri, author_did, feed_url, article_url, tags, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
352+ "at://alice/ann/2", "did:test:alice", "https://a.com/feed", "https://a.com/art2", "rust,programming")
353+ assert.NilError(t, err)
354+ _, err = database.ExecContext(ctx, `INSERT INTO annotations (uri, author_did, feed_url, article_url, tags, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
355+ "at://carol/ann/1", "did:test:carol", "https://c.com/feed", "https://c.com/art1", "go,web")
356+ assert.NilError(t, err)
357+
358+ engine := NewEngine(database.DB, slog.Default())
359+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
360+
361+ var jaccard float64
362+ var commonTags int
363+ assert.NilError(t, database.QueryRowContext(ctx,
364+ `SELECT jaccard, common_tags FROM user_similarity WHERE user_a = ? AND user_b = ?`,
365+ "did:test:alice", "did:test:carol").Scan(&jaccard, &commonTags))
366+ assert.Equal(t, commonTags, 1, "alice and carol share 1 tag (go)")
367+ assert.Assert(t, jaccard > 0, "tags should contribute to similarity, got %f", jaccard)
368+}
369+
370+func TestDescriptionBasedFeedSimilarity(t *testing.T) {
371+ ctx := context.Background()
372+ database := setupClusterTestDB(t)
373+
374+ _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:alice", "alice")
375+ assert.NilError(t, err)
376+ _, err = database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:bob", "bob")
377+ assert.NilError(t, err)
378+
379+ _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
380+ "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")
381+ assert.NilError(t, err)
382+ _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
383+ "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")
384+ assert.NilError(t, err)
385+
386+ _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://go.com/feed")
387+ assert.NilError(t, err)
388+ _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://rust.com/feed")
389+ assert.NilError(t, err)
390+
391+ engine := NewEngine(database.DB, slog.Default())
392+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
393+
394+ var count int
395+ assert.NilError(t, database.QueryRowContext(ctx, `SELECT COUNT(*) FROM feed_similarity`).Scan(&count))
396+ assert.Assert(t, count >= 0, "description-based similarity should produce pairs")
397+
398+ if count > 0 {
399+ var jaccard float64
400+ assert.NilError(t, database.QueryRowContext(ctx,
401+ `SELECT jaccard FROM feed_similarity WHERE feed_a = ? AND feed_b = ?`,
402+ "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
403+ assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")
404+ }
405+}
@@ -110,7 +110,7 @@ func TestComputeRecommendations_GeneratesFeedRecsForNewUser(t *testing.T) {
110 110
111 var found bool111 var found bool
112 for _, r := range recs {112 for _, r := range recs {
113- if r["feed_url"] == "https://a.com/feed" || r["feed_url"] == "https://b.com/feed" {113+ if r.FeedURL == "https://a.com/feed" || r.FeedURL == "https://b.com/feed" {
114 found = true114 found = true
115 }115 }
116 }116 }
@@ -136,7 +136,7 @@ func TestComputeRecommendations_NoSelfRecommendations(t *testing.T) {
136 "https://c.com/feed": true,136 "https://c.com/feed": true,
137 }137 }
138 for _, r := range recs {138 for _, r := range recs {
139- assert.Assert(t, !subscribedFeeds[r["feed_url"].(string)],139+ assert.Assert(t, !subscribedFeeds[r.FeedURL],
140 "should not recommend a feed the user already subscribes to")140 "should not recommend a feed the user already subscribes to")
141 }141 }
142 }142 }
@@ -257,7 +257,7 @@ func TestComputeRecommendationsForUser(t *testing.T) {
257 257
258 var found bool258 var found bool
259 for _, r := range recs {259 for _, r := range recs {
260- if r["feed_url"] == "https://a.com/feed" || r["feed_url"] == "https://b.com/feed" {260+ if r.FeedURL == "https://a.com/feed" || r.FeedURL == "https://b.com/feed" {
261 found = true261 found = true
262 }262 }
263 }263 }
@@ -305,3 +305,101 @@ func TestComputeRecommendationsForUser_MatchesFullCompute(t *testing.T) {
305 assert.Equal(t, score, incrScore, "score mismatch for %s", url)305 assert.Equal(t, score, incrScore, "score mismatch for %s", url)
306 }306 }
307 }307 }
308+
309+func TestLikesBasedSimilarity(t *testing.T) {
310+ ctx := context.Background()
311+ database := setupClusterTestDB(t)
312+ seedClusterData(t, ctx, database)
313+
314+ _, err := database.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
315+ "https://a.com/feed", "art1", "Article 1", "https://a.com/art1")
316+ assert.NilError(t, err)
317+ _, err = database.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
318+ "https://a.com/feed", "art2", "Article 2", "https://a.com/art2")
319+ assert.NilError(t, err)
320+
321+ _, err = database.ExecContext(ctx, `INSERT INTO likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
322+ "at://alice/like/1", "did:test:alice", "https://a.com/feed", "https://a.com/art1")
323+ assert.NilError(t, err)
324+ _, err = database.ExecContext(ctx, `INSERT INTO likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
325+ "at://alice/like/2", "did:test:alice", "https://a.com/feed", "https://a.com/art2")
326+ assert.NilError(t, err)
327+ _, err = database.ExecContext(ctx, `INSERT INTO likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
328+ "at://carol/like/1", "did:test:carol", "https://a.com/feed", "https://a.com/art1")
329+ assert.NilError(t, err)
330+
331+ engine := NewEngine(database.DB, slog.Default())
332+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
333+
334+ var jaccard float64
335+ var commonLikes int
336+ assert.NilError(t, database.QueryRowContext(ctx,
337+ `SELECT jaccard, common_likes FROM user_similarity WHERE user_a = ? AND user_b = ?`,
338+ "did:test:alice", "did:test:carol").Scan(&jaccard, &commonLikes))
339+ assert.Equal(t, commonLikes, 1, "alice and carol share 1 liked article")
340+ assert.Assert(t, jaccard > 0, "likes should contribute to similarity, got %f", jaccard)
341+}
342+
343+func TestTagsBasedSimilarity(t *testing.T) {
344+ ctx := context.Background()
345+ database := setupClusterTestDB(t)
346+ seedClusterData(t, ctx, database)
347+
348+ _, err := database.ExecContext(ctx, `INSERT INTO annotations (uri, author_did, feed_url, article_url, tags, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
349+ "at://alice/ann/1", "did:test:alice", "https://a.com/feed", "https://a.com/art1", "go,programming")
350+ assert.NilError(t, err)
351+ _, err = database.ExecContext(ctx, `INSERT INTO annotations (uri, author_did, feed_url, article_url, tags, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
352+ "at://alice/ann/2", "did:test:alice", "https://a.com/feed", "https://a.com/art2", "rust,programming")
353+ assert.NilError(t, err)
354+ _, err = database.ExecContext(ctx, `INSERT INTO annotations (uri, author_did, feed_url, article_url, tags, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
355+ "at://carol/ann/1", "did:test:carol", "https://c.com/feed", "https://c.com/art1", "go,web")
356+ assert.NilError(t, err)
357+
358+ engine := NewEngine(database.DB, slog.Default())
359+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
360+
361+ var jaccard float64
362+ var commonTags int
363+ assert.NilError(t, database.QueryRowContext(ctx,
364+ `SELECT jaccard, common_tags FROM user_similarity WHERE user_a = ? AND user_b = ?`,
365+ "did:test:alice", "did:test:carol").Scan(&jaccard, &commonTags))
366+ assert.Equal(t, commonTags, 1, "alice and carol share 1 tag (go)")
367+ assert.Assert(t, jaccard > 0, "tags should contribute to similarity, got %f", jaccard)
368+}
369+
370+func TestDescriptionBasedFeedSimilarity(t *testing.T) {
371+ ctx := context.Background()
372+ database := setupClusterTestDB(t)
373+
374+ _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:alice", "alice")
375+ assert.NilError(t, err)
376+ _, err = database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:bob", "bob")
377+ assert.NilError(t, err)
378+
379+ _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
380+ "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")
381+ assert.NilError(t, err)
382+ _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
383+ "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")
384+ assert.NilError(t, err)
385+
386+ _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://go.com/feed")
387+ assert.NilError(t, err)
388+ _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://rust.com/feed")
389+ assert.NilError(t, err)
390+
391+ engine := NewEngine(database.DB, slog.Default())
392+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
393+
394+ var count int
395+ assert.NilError(t, database.QueryRowContext(ctx, `SELECT COUNT(*) FROM feed_similarity`).Scan(&count))
396+ assert.Assert(t, count >= 0, "description-based similarity should produce pairs")
397+
398+ if count > 0 {
399+ var jaccard float64
400+ assert.NilError(t, database.QueryRowContext(ctx,
401+ `SELECT jaccard FROM feed_similarity WHERE feed_a = ? AND feed_b = ?`,
402+ "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
403+ assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")
404+ }
405+}
modified internal/cluster/recommender.go +98 -45
@@ -2,11 +2,55 @@ package cluster
22
33 import (
44 "context"
5+ "database/sql"
56 )
67
7-func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
8+type FeedRecommendation struct {
9+ FeedURL string
10+ Title string
11+ SiteURL string
12+ Description string
13+ SubscriberCount int
14+ FaviconURL string
15+ Score float64
16+}
17+
18+type PersonRecommendation struct {
19+ DID string
20+ Handle string
21+ DisplayName string
22+ AvatarURL string
23+ Jaccard float64
24+ CommonFeeds int
25+ CommonLikes int
26+ CommonTags int
27+}
28+
29+type ArticleRecommendation struct {
30+ ArticleID int64
31+ Title string
32+ URL string
33+ FeedURL string
34+ FeedTitle string
35+ Author string
36+ Summary string
37+ Published sql.NullTime
38+ Score float64
39+}
40+
41+type SimilarFeed struct {
42+ FeedURL string
43+ Title string
44+ SiteURL string
45+ Description string
46+ FeedType string
47+ Jaccard float64
48+}
49+
50+func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
851 rows, err := e.db.QueryContext(ctx, `
9- SELECT r.feed_url, f.title, f.site_url, f.description, f.feed_type, r.score
52+ SELECT r.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
53+ COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, ''), r.score
1054 FROM user_feed_recommendations r
1155 JOIN feeds f ON f.feed_url = r.feed_url
1256 WHERE r.user_did = ?
@@ -18,34 +62,29 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
1862 }
1963 defer rows.Close()
2064
21- var results []map[string]any
65+ var results []*FeedRecommendation
2266 for rows.Next() {
23- var feedURL, title, siteURL, description, feedType string
24- var score float64
25- if err := rows.Scan(&feedURL, &title, &siteURL, &description, &feedType, &score); err != nil {
67+ rec := &FeedRecommendation{}
68+ if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
69+ &rec.SubscriberCount, &rec.FaviconURL, &rec.Score); err != nil {
2670 return nil, err
2771 }
28- results = append(results, map[string]any{
29- "feed_url": feedURL,
30- "title": title,
31- "site_url": siteURL,
32- "description": description,
33- "feed_type": feedType,
34- "score": score,
35- })
72+ results = append(results, rec)
3673 }
3774 return results, rows.Err()
3875 }
3976
40-func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
77+func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
4178 rows, err := e.db.QueryContext(ctx, `
42- SELECT u.did, u.handle, u.display_name, u.avatar_url, sim.jaccard, sim.common_feeds
79+ SELECT u.did, u.handle, COALESCE(u.display_name, ''), COALESCE(u.avatar_url, ''),
80+ sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0)
4381 FROM (
44- SELECT user_b AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_a = ?
82+ SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_a = ?
4583 UNION ALL
46- SELECT user_a AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_b = ?
84+ SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_b = ?
4785 ) sim
4886 JOIN users u ON u.did = sim.peer_did
87+ WHERE u.handle IS NOT NULL AND u.handle != ''
4988 ORDER BY sim.jaccard DESC
5089 LIMIT ?
5190 `, userDID, userDID, limit)
@@ -54,29 +93,50 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
5493 }
5594 defer rows.Close()
5695
57- var results []map[string]any
96+ var results []*PersonRecommendation
5897 for rows.Next() {
59- var did, handle, displayName, avatarURL string
60- var jaccard float64
61- var commonFeeds int
62- if err := rows.Scan(&did, &handle, &displayName, &avatarURL, &jaccard, &commonFeeds); err != nil {
98+ rec := &PersonRecommendation{}
99+ if err := rows.Scan(&rec.DID, &rec.Handle, &rec.DisplayName, &rec.AvatarURL,
100+ &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags); err != nil {
63101 return nil, err
64102 }
65- results = append(results, map[string]any{
66- "did": did,
67- "handle": handle,
68- "display_name": displayName,
69- "avatar_url": avatarURL,
70- "jaccard": jaccard,
71- "common_feeds": commonFeeds,
72- })
103+ results = append(results, rec)
73104 }
74105 return results, rows.Err()
75106 }
76107
77-func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]map[string]any, error) {
108+func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
109+ rows, err := e.db.QueryContext(ctx, `
110+ SELECT a.id, a.title, COALESCE(a.url, ''), r.feed_url, COALESCE(f.title, ''),
111+ COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, r.score
112+ FROM user_article_recommendations r
113+ JOIN articles a ON a.feed_url = r.feed_url AND a.url = r.article_url
114+ LEFT JOIN feeds f ON f.feed_url = r.feed_url
115+ WHERE r.user_did = ?
116+ ORDER BY r.score DESC
117+ LIMIT ?
118+ `, userDID, limit)
119+ if err != nil {
120+ return nil, err
121+ }
122+ defer rows.Close()
123+
124+ var recs []*ArticleRecommendation
125+ for rows.Next() {
126+ rec := &ArticleRecommendation{}
127+ if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle,
128+ &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil {
129+ return nil, err
130+ }
131+ recs = append(recs, rec)
132+ }
133+ return recs, rows.Err()
134+}
135+
136+func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*SimilarFeed, error) {
78137 rows, err := e.db.QueryContext(ctx, `
79- SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, sim.jaccard
138+ SELECT f.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
139+ COALESCE(f.description, ''), COALESCE(f.feed_type, ''), sim.jaccard
80140 FROM (
81141 SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ?
82142 UNION ALL
@@ -91,21 +151,14 @@ func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int)
91151 }
92152 defer rows.Close()
93153
94- var results []map[string]any
154+ var results []*SimilarFeed
95155 for rows.Next() {
96- var peerURL, title, siteURL, description, feedType string
97- var jaccard float64
98- if err := rows.Scan(&peerURL, &title, &siteURL, &description, &feedType, &jaccard); err != nil {
156+ rec := &SimilarFeed{}
157+ if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
158+ &rec.FeedType, &rec.Jaccard); err != nil {
99159 return nil, err
100160 }
101- results = append(results, map[string]any{
102- "feed_url": peerURL,
103- "title": title,
104- "site_url": siteURL,
105- "description": description,
106- "feed_type": feedType,
107- "jaccard": jaccard,
108- })
161+ results = append(results, rec)
109162 }
110163 return results, rows.Err()
111164 }
@@ -2,11 +2,55 @@ package cluster
2 2
3 import (3 import (
4 "context"4 "context"
5+ "database/sql"
5 )6 )
6 7
7-func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {8+type FeedRecommendation struct {
9+ FeedURL string
10+ Title string
11+ SiteURL string
12+ Description string
13+ SubscriberCount int
14+ FaviconURL string
15+ Score float64
16+}
17+
18+type PersonRecommendation struct {
19+ DID string
20+ Handle string
21+ DisplayName string
22+ AvatarURL string
23+ Jaccard float64
24+ CommonFeeds int
25+ CommonLikes int
26+ CommonTags int
27+}
28+
29+type ArticleRecommendation struct {
30+ ArticleID int64
31+ Title string
32+ URL string
33+ FeedURL string
34+ FeedTitle string
35+ Author string
36+ Summary string
37+ Published sql.NullTime
38+ Score float64
39+}
40+
41+type SimilarFeed struct {
42+ FeedURL string
43+ Title string
44+ SiteURL string
45+ Description string
46+ FeedType string
47+ Jaccard float64
48+}
49+
50+func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
8 rows, err := e.db.QueryContext(ctx, `51 rows, err := e.db.QueryContext(ctx, `
9- SELECT r.feed_url, f.title, f.site_url, f.description, f.feed_type, r.score52+ SELECT r.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
53+ COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, ''), r.score
10 FROM user_feed_recommendations r54 FROM user_feed_recommendations r
11 JOIN feeds f ON f.feed_url = r.feed_url55 JOIN feeds f ON f.feed_url = r.feed_url
12 WHERE r.user_did = ?56 WHERE r.user_did = ?
@@ -18,34 +62,29 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim
18 }62 }
19 defer rows.Close()63 defer rows.Close()
20 64
21- var results []map[string]any65+ var results []*FeedRecommendation
22 for rows.Next() {66 for rows.Next() {
23- var feedURL, title, siteURL, description, feedType string67+ rec := &FeedRecommendation{}
24- var score float6468+ if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
25- if err := rows.Scan(&feedURL, &title, &siteURL, &description, &feedType, &score); err != nil {69+ &rec.SubscriberCount, &rec.FaviconURL, &rec.Score); err != nil {
26 return nil, err70 return nil, err
27 }71 }
28- results = append(results, map[string]any{72+ results = append(results, rec)
29- "feed_url": feedURL,
30- "title": title,
31- "site_url": siteURL,
32- "description": description,
33- "feed_type": feedType,
34- "score": score,
35- })
36 }73 }
37 return results, rows.Err()74 return results, rows.Err()
38 }75 }
39 76
40-func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {77+func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) {
41 rows, err := e.db.QueryContext(ctx, `78 rows, err := e.db.QueryContext(ctx, `
42- SELECT u.did, u.handle, u.display_name, u.avatar_url, sim.jaccard, sim.common_feeds79+ SELECT u.did, u.handle, COALESCE(u.display_name, ''), COALESCE(u.avatar_url, ''),
80+ sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0)
43 FROM (81 FROM (
44- SELECT user_b AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_a = ?82+ SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_a = ?
45 UNION ALL83 UNION ALL
46- SELECT user_a AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_b = ?84+ SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_b = ?
47 ) sim85 ) sim
48 JOIN users u ON u.did = sim.peer_did86 JOIN users u ON u.did = sim.peer_did
87+ WHERE u.handle IS NOT NULL AND u.handle != ''
49 ORDER BY sim.jaccard DESC88 ORDER BY sim.jaccard DESC
50 LIMIT ?89 LIMIT ?
51 `, userDID, userDID, limit)90 `, userDID, userDID, limit)
@@ -54,29 +93,50 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
54 }93 }
55 defer rows.Close()94 defer rows.Close()
56 95
57- var results []map[string]any96+ var results []*PersonRecommendation
58 for rows.Next() {97 for rows.Next() {
59- var did, handle, displayName, avatarURL string98+ rec := &PersonRecommendation{}
60- var jaccard float6499+ if err := rows.Scan(&rec.DID, &rec.Handle, &rec.DisplayName, &rec.AvatarURL,
61- var commonFeeds int100+ &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags); err != nil {
62- if err := rows.Scan(&did, &handle, &displayName, &avatarURL, &jaccard, &commonFeeds); err != nil {
63 return nil, err101 return nil, err
64 }102 }
65- results = append(results, map[string]any{103+ results = append(results, rec)
66- "did": did,
67- "handle": handle,
68- "display_name": displayName,
69- "avatar_url": avatarURL,
70- "jaccard": jaccard,
71- "common_feeds": commonFeeds,
72- })
73 }104 }
74 return results, rows.Err()105 return results, rows.Err()
75 }106 }
76 107
77-func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]map[string]any, error) {108+func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
109+ rows, err := e.db.QueryContext(ctx, `
110+ SELECT a.id, a.title, COALESCE(a.url, ''), r.feed_url, COALESCE(f.title, ''),
111+ COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, r.score
112+ FROM user_article_recommendations r
113+ JOIN articles a ON a.feed_url = r.feed_url AND a.url = r.article_url
114+ LEFT JOIN feeds f ON f.feed_url = r.feed_url
115+ WHERE r.user_did = ?
116+ ORDER BY r.score DESC
117+ LIMIT ?
118+ `, userDID, limit)
119+ if err != nil {
120+ return nil, err
121+ }
122+ defer rows.Close()
123+
124+ var recs []*ArticleRecommendation
125+ for rows.Next() {
126+ rec := &ArticleRecommendation{}
127+ if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle,
128+ &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil {
129+ return nil, err
130+ }
131+ recs = append(recs, rec)
132+ }
133+ return recs, rows.Err()
134+}
135+
136+func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*SimilarFeed, error) {
78 rows, err := e.db.QueryContext(ctx, `137 rows, err := e.db.QueryContext(ctx, `
79- SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, sim.jaccard138+ SELECT f.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
139+ COALESCE(f.description, ''), COALESCE(f.feed_type, ''), sim.jaccard
80 FROM (140 FROM (
81 SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ?141 SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ?
82 UNION ALL142 UNION ALL
@@ -91,21 +151,14 @@ func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int)
91 }151 }
92 defer rows.Close()152 defer rows.Close()
93 153
94- var results []map[string]any154+ var results []*SimilarFeed
95 for rows.Next() {155 for rows.Next() {
96- var peerURL, title, siteURL, description, feedType string156+ rec := &SimilarFeed{}
97- var jaccard float64157+ if err := rows.Scan(&rec.FeedURL, &rec.Title, &rec.SiteURL, &rec.Description,
98- if err := rows.Scan(&peerURL, &title, &siteURL, &description, &feedType, &jaccard); err != nil {158+ &rec.FeedType, &rec.Jaccard); err != nil {
99 return nil, err159 return nil, err
100 }160 }
101- results = append(results, map[string]any{161+ results = append(results, rec)
102- "feed_url": peerURL,
103- "title": title,
104- "site_url": siteURL,
105- "description": description,
106- "feed_type": feedType,
107- "jaccard": jaccard,
108- })
109 }162 }
110 return results, rows.Err()163 return results, rows.Err()
111 }164 }
deleted internal/db/cluster.go +0 -385
deleted file mode 100644
@@ -1,385 +0,0 @@
1-package db
2-
3-import (
4- "context"
5- "database/sql"
6-)
7-
8-func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
9- tx, err := db.BeginTx(ctx, nil)
10- if err != nil {
11- return err
12- }
13- defer tx.Rollback()
14-
15- _, err = tx.ExecContext(ctx, `DELETE FROM feed_similarity`)
16- if err != nil {
17- return err
18- }
19-
20- rows, err := tx.QueryContext(ctx, `
21- SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap
22- FROM subscriptions s1
23- JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
24- GROUP BY s1.feed_url, s2.feed_url
25- HAVING overlap > 0
26- `)
27- if err != nil {
28- return err
29- }
30- defer rows.Close()
31-
32- type pair struct {
33- feedA string
34- feedB string
35- overlap int
36- }
37- var pairs []pair
38- for rows.Next() {
39- var p pair
40- if err := rows.Scan(&p.feedA, &p.feedB, &p.overlap); err != nil {
41- return err
42- }
43- pairs = append(pairs, p)
44- }
45- if err := rows.Err(); err != nil {
46- return err
47- }
48-
49- subCounts := make(map[string]int)
50- for _, p := range pairs {
51- subCounts[p.feedA] = 0
52- subCounts[p.feedB] = 0
53- }
54-
55- if len(subCounts) > 0 {
56- countRows, err := tx.QueryContext(ctx, `
57- SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url
58- `)
59- if err != nil {
60- return err
61- }
62- for countRows.Next() {
63- var feedURL string
64- var count int
65- if err := countRows.Scan(&feedURL, &count); err != nil {
66- countRows.Close()
67- return err
68- }
69- subCounts[feedURL] = count
70- }
71- countRows.Close()
72- }
73-
74- stmt, err := tx.PrepareContext(ctx, `
75- INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
76- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
77- `)
78- if err != nil {
79- return err
80- }
81- defer stmt.Close()
82-
83- for _, p := range pairs {
84- total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap
85- if total == 0 {
86- continue
87- }
88- jaccard := float64(p.overlap) / float64(total)
89- if _, err := stmt.ExecContext(ctx, p.feedA, p.feedB, jaccard); err != nil {
90- return err
91- }
92- }
93-
94- return tx.Commit()
95-}
96-
97-func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
98- tx, err := db.BeginTx(ctx, nil)
99- if err != nil {
100- return err
101- }
102- defer tx.Rollback()
103-
104- _, err = tx.ExecContext(ctx, `DELETE FROM user_similarity`)
105- if err != nil {
106- return err
107- }
108-
109- rows, err := tx.QueryContext(ctx, `
110- SELECT s1.user_did, s2.user_did, COUNT(*) AS common
111- FROM subscriptions s1
112- JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url
113- GROUP BY s1.user_did, s2.user_did
114- HAVING common > 0
115- `)
116- if err != nil {
117- return err
118- }
119- defer rows.Close()
120-
121- type pair struct {
122- userA string
123- userB string
124- common int
125- }
126- var pairs []pair
127- for rows.Next() {
128- var p pair
129- if err := rows.Scan(&p.userA, &p.userB, &p.common); err != nil {
130- return err
131- }
132- pairs = append(pairs, p)
133- }
134- if err := rows.Err(); err != nil {
135- return err
136- }
137-
138- subCounts := make(map[string]int)
139- for _, p := range pairs {
140- subCounts[p.userA] = 0
141- subCounts[p.userB] = 0
142- }
143-
144- if len(subCounts) > 0 {
145- countRows, err := tx.QueryContext(ctx, `
146- SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did
147- `)
148- if err != nil {
149- return err
150- }
151- for countRows.Next() {
152- var userDID string
153- var count int
154- if err := countRows.Scan(&userDID, &count); err != nil {
155- countRows.Close()
156- return err
157- }
158- subCounts[userDID] = count
159- }
160- countRows.Close()
161- }
162-
163- stmt, err := tx.PrepareContext(ctx, `
164- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
165- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
166- `)
167- if err != nil {
168- return err
169- }
170- defer stmt.Close()
171-
172- for _, p := range pairs {
173- total := subCounts[p.userA] + subCounts[p.userB] - p.common
174- if total == 0 {
175- continue
176- }
177- jaccard := float64(p.common) / float64(total)
178- if _, err := stmt.ExecContext(ctx, p.userA, p.userB, jaccard, p.common); err != nil {
179- return err
180- }
181- }
182-
183- return tx.Commit()
184-}
185-
186-func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {
187- tx, err := db.BeginTx(ctx, nil)
188- if err != nil {
189- return err
190- }
191- defer tx.Rollback()
192-
193- _, err = tx.ExecContext(ctx, `
194- DELETE FROM user_feed_recommendations WHERE user_did = ?
195- `, userDID)
196- if err != nil {
197- return err
198- }
199-
200- rows, err := tx.QueryContext(ctx, `
201- SELECT
202- CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,
203- SUM(fs.jaccard) AS score
204- FROM feed_similarity fs
205- WHERE fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
206- OR fs.feed_b IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
207- GROUP BY recommended_feed
208- HAVING recommended_feed NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
209- ORDER BY score DESC
210- `, userDID, userDID, userDID, userDID)
211- if err != nil {
212- return err
213- }
214- defer rows.Close()
215-
216- stmt, err := tx.PrepareContext(ctx, `
217- INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
218- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
219- `)
220- if err != nil {
221- return err
222- }
223- defer stmt.Close()
224-
225- for rows.Next() {
226- var feedURL string
227- var score float64
228- if err := rows.Scan(&feedURL, &score); err != nil {
229- return err
230- }
231- if _, err := stmt.ExecContext(ctx, userDID, feedURL, score); err != nil {
232- return err
233- }
234- }
235- if err := rows.Err(); err != nil {
236- return err
237- }
238-
239- return tx.Commit()
240-}
241-
242-func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
243- rows, err := db.QueryContext(ctx, `
244- SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count, f.favicon_url
245- FROM user_feed_recommendations r
246- JOIN feeds f ON f.feed_url = r.feed_url
247- WHERE r.user_did = ?
248- ORDER BY r.score DESC
249- LIMIT ?
250- `, userDID, limit)
251- if err != nil {
252- return nil, err
253- }
254- defer rows.Close()
255-
256- var results []map[string]any
257- for rows.Next() {
258- var feedURL string
259- var score float64
260- var title, siteURL, description sql.NullString
261- var faviconURL sql.NullString
262- var subCount int
263- if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount, &faviconURL); err != nil {
264- return nil, err
265- }
266- results = append(results, map[string]any{
267- "feed_url": feedURL,
268- "score": score,
269- "title": title.String,
270- "site_url": siteURL.String,
271- "description": description.String,
272- "subscriber_count": subCount,
273- "favicon_url": faviconURL.String,
274- })
275- }
276- return results, rows.Err()
277-}
278-
279-func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
280- rows, err := db.QueryContext(ctx, `
281- SELECT
282- CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,
283- us.jaccard, us.common_feeds,
284- u.handle, u.display_name, u.avatar_url
285- FROM user_similarity us
286- JOIN users u ON u.did = CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
287- WHERE (us.user_a = ? OR us.user_b = ?)
288- AND u.handle IS NOT NULL AND u.handle != ''
289- ORDER BY us.jaccard DESC
290- LIMIT ?
291- `, userDID, userDID, userDID, userDID, limit)
292- if err != nil {
293- return nil, err
294- }
295- defer rows.Close()
296-
297- var results []map[string]any
298- for rows.Next() {
299- var recUser, handle string
300- var jaccard float64
301- var commonFeeds int
302- var displayName, avatarURL sql.NullString
303- if err := rows.Scan(&recUser, &jaccard, &commonFeeds, &handle, &displayName, &avatarURL); err != nil {
304- return nil, err
305- }
306- results = append(results, map[string]any{
307- "did": recUser,
308- "jaccard": jaccard,
309- "common_feeds": commonFeeds,
310- "handle": handle,
311- "display_name": displayName.String,
312- "avatar_url": avatarURL.String,
313- })
314- }
315- return results, rows.Err()
316-}
317-
318-func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*Feed, error) {
319- rows, err := db.QueryContext(ctx, `
320- SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
321- f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,
322- f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count, f.favicon_url
323- FROM feed_similarity fs
324- JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END
325- WHERE fs.feed_a = ? OR fs.feed_b = ?
326- ORDER BY fs.jaccard DESC
327- LIMIT ?
328- `, feedURL, feedURL, feedURL, limit)
329- if err != nil {
330- return nil, err
331- }
332- defer rows.Close()
333-
334- var feeds []*Feed
335- for rows.Next() {
336- f := &Feed{}
337- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
338- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
339- &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
340- return nil, err
341- }
342- feeds = append(feeds, f)
343- }
344- return feeds, rows.Err()
345-}
346-
347-type ArticleRecommendation struct {
348- ArticleID int64
349- Title string
350- URL string
351- FeedURL string
352- FeedTitle string
353- Author string
354- Summary string
355- Published sql.NullTime
356- Score float64
357-}
358-
359-func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
360- rows, err := db.QueryContext(ctx, `
361- SELECT a.id, a.title, COALESCE(a.url, ''), r.feed_url, COALESCE(f.title, ''),
362- COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, r.score
363- FROM user_article_recommendations r
364- JOIN articles a ON a.feed_url = r.feed_url AND a.url = r.article_url
365- LEFT JOIN feeds f ON f.feed_url = r.feed_url
366- WHERE r.user_did = ?
367- ORDER BY r.score DESC
368- LIMIT ?
369- `, userDID, limit)
370- if err != nil {
371- return nil, err
372- }
373- defer rows.Close()
374-
375- var recs []*ArticleRecommendation
376- for rows.Next() {
377- rec := &ArticleRecommendation{}
378- if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle,
379- &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil {
380- return nil, err
381- }
382- recs = append(recs, rec)
383- }
384- return recs, rows.Err()
385-}
deleted file mode 100644
@@ -1,385 +0,0 @@
1-package db
2-
3-import (
4- "context"
5- "database/sql"
6-)
7-
8-func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
9- tx, err := db.BeginTx(ctx, nil)
10- if err != nil {
11- return err
12- }
13- defer tx.Rollback()
14-
15- _, err = tx.ExecContext(ctx, `DELETE FROM feed_similarity`)
16- if err != nil {
17- return err
18- }
19-
20- rows, err := tx.QueryContext(ctx, `
21- SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap
22- FROM subscriptions s1
23- JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
24- GROUP BY s1.feed_url, s2.feed_url
25- HAVING overlap > 0
26- `)
27- if err != nil {
28- return err
29- }
30- defer rows.Close()
31-
32- type pair struct {
33- feedA string
34- feedB string
35- overlap int
36- }
37- var pairs []pair
38- for rows.Next() {
39- var p pair
40- if err := rows.Scan(&p.feedA, &p.feedB, &p.overlap); err != nil {
41- return err
42- }
43- pairs = append(pairs, p)
44- }
45- if err := rows.Err(); err != nil {
46- return err
47- }
48-
49- subCounts := make(map[string]int)
50- for _, p := range pairs {
51- subCounts[p.feedA] = 0
52- subCounts[p.feedB] = 0
53- }
54-
55- if len(subCounts) > 0 {
56- countRows, err := tx.QueryContext(ctx, `
57- SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url
58- `)
59- if err != nil {
60- return err
61- }
62- for countRows.Next() {
63- var feedURL string
64- var count int
65- if err := countRows.Scan(&feedURL, &count); err != nil {
66- countRows.Close()
67- return err
68- }
69- subCounts[feedURL] = count
70- }
71- countRows.Close()
72- }
73-
74- stmt, err := tx.PrepareContext(ctx, `
75- INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
76- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
77- `)
78- if err != nil {
79- return err
80- }
81- defer stmt.Close()
82-
83- for _, p := range pairs {
84- total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap
85- if total == 0 {
86- continue
87- }
88- jaccard := float64(p.overlap) / float64(total)
89- if _, err := stmt.ExecContext(ctx, p.feedA, p.feedB, jaccard); err != nil {
90- return err
91- }
92- }
93-
94- return tx.Commit()
95-}
96-
97-func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
98- tx, err := db.BeginTx(ctx, nil)
99- if err != nil {
100- return err
101- }
102- defer tx.Rollback()
103-
104- _, err = tx.ExecContext(ctx, `DELETE FROM user_similarity`)
105- if err != nil {
106- return err
107- }
108-
109- rows, err := tx.QueryContext(ctx, `
110- SELECT s1.user_did, s2.user_did, COUNT(*) AS common
111- FROM subscriptions s1
112- JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url
113- GROUP BY s1.user_did, s2.user_did
114- HAVING common > 0
115- `)
116- if err != nil {
117- return err
118- }
119- defer rows.Close()
120-
121- type pair struct {
122- userA string
123- userB string
124- common int
125- }
126- var pairs []pair
127- for rows.Next() {
128- var p pair
129- if err := rows.Scan(&p.userA, &p.userB, &p.common); err != nil {
130- return err
131- }
132- pairs = append(pairs, p)
133- }
134- if err := rows.Err(); err != nil {
135- return err
136- }
137-
138- subCounts := make(map[string]int)
139- for _, p := range pairs {
140- subCounts[p.userA] = 0
141- subCounts[p.userB] = 0
142- }
143-
144- if len(subCounts) > 0 {
145- countRows, err := tx.QueryContext(ctx, `
146- SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did
147- `)
148- if err != nil {
149- return err
150- }
151- for countRows.Next() {
152- var userDID string
153- var count int
154- if err := countRows.Scan(&userDID, &count); err != nil {
155- countRows.Close()
156- return err
157- }
158- subCounts[userDID] = count
159- }
160- countRows.Close()
161- }
162-
163- stmt, err := tx.PrepareContext(ctx, `
164- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
165- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
166- `)
167- if err != nil {
168- return err
169- }
170- defer stmt.Close()
171-
172- for _, p := range pairs {
173- total := subCounts[p.userA] + subCounts[p.userB] - p.common
174- if total == 0 {
175- continue
176- }
177- jaccard := float64(p.common) / float64(total)
178- if _, err := stmt.ExecContext(ctx, p.userA, p.userB, jaccard, p.common); err != nil {
179- return err
180- }
181- }
182-
183- return tx.Commit()
184-}
185-
186-func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {
187- tx, err := db.BeginTx(ctx, nil)
188- if err != nil {
189- return err
190- }
191- defer tx.Rollback()
192-
193- _, err = tx.ExecContext(ctx, `
194- DELETE FROM user_feed_recommendations WHERE user_did = ?
195- `, userDID)
196- if err != nil {
197- return err
198- }
199-
200- rows, err := tx.QueryContext(ctx, `
201- SELECT
202- CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,
203- SUM(fs.jaccard) AS score
204- FROM feed_similarity fs
205- WHERE fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
206- OR fs.feed_b IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
207- GROUP BY recommended_feed
208- HAVING recommended_feed NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
209- ORDER BY score DESC
210- `, userDID, userDID, userDID, userDID)
211- if err != nil {
212- return err
213- }
214- defer rows.Close()
215-
216- stmt, err := tx.PrepareContext(ctx, `
217- INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
218- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
219- `)
220- if err != nil {
221- return err
222- }
223- defer stmt.Close()
224-
225- for rows.Next() {
226- var feedURL string
227- var score float64
228- if err := rows.Scan(&feedURL, &score); err != nil {
229- return err
230- }
231- if _, err := stmt.ExecContext(ctx, userDID, feedURL, score); err != nil {
232- return err
233- }
234- }
235- if err := rows.Err(); err != nil {
236- return err
237- }
238-
239- return tx.Commit()
240-}
241-
242-func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
243- rows, err := db.QueryContext(ctx, `
244- SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count, f.favicon_url
245- FROM user_feed_recommendations r
246- JOIN feeds f ON f.feed_url = r.feed_url
247- WHERE r.user_did = ?
248- ORDER BY r.score DESC
249- LIMIT ?
250- `, userDID, limit)
251- if err != nil {
252- return nil, err
253- }
254- defer rows.Close()
255-
256- var results []map[string]any
257- for rows.Next() {
258- var feedURL string
259- var score float64
260- var title, siteURL, description sql.NullString
261- var faviconURL sql.NullString
262- var subCount int
263- if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount, &faviconURL); err != nil {
264- return nil, err
265- }
266- results = append(results, map[string]any{
267- "feed_url": feedURL,
268- "score": score,
269- "title": title.String,
270- "site_url": siteURL.String,
271- "description": description.String,
272- "subscriber_count": subCount,
273- "favicon_url": faviconURL.String,
274- })
275- }
276- return results, rows.Err()
277-}
278-
279-func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
280- rows, err := db.QueryContext(ctx, `
281- SELECT
282- CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,
283- us.jaccard, us.common_feeds,
284- u.handle, u.display_name, u.avatar_url
285- FROM user_similarity us
286- JOIN users u ON u.did = CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
287- WHERE (us.user_a = ? OR us.user_b = ?)
288- AND u.handle IS NOT NULL AND u.handle != ''
289- ORDER BY us.jaccard DESC
290- LIMIT ?
291- `, userDID, userDID, userDID, userDID, limit)
292- if err != nil {
293- return nil, err
294- }
295- defer rows.Close()
296-
297- var results []map[string]any
298- for rows.Next() {
299- var recUser, handle string
300- var jaccard float64
301- var commonFeeds int
302- var displayName, avatarURL sql.NullString
303- if err := rows.Scan(&recUser, &jaccard, &commonFeeds, &handle, &displayName, &avatarURL); err != nil {
304- return nil, err
305- }
306- results = append(results, map[string]any{
307- "did": recUser,
308- "jaccard": jaccard,
309- "common_feeds": commonFeeds,
310- "handle": handle,
311- "display_name": displayName.String,
312- "avatar_url": avatarURL.String,
313- })
314- }
315- return results, rows.Err()
316-}
317-
318-func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*Feed, error) {
319- rows, err := db.QueryContext(ctx, `
320- SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
321- f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,
322- f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count, f.favicon_url
323- FROM feed_similarity fs
324- JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END
325- WHERE fs.feed_a = ? OR fs.feed_b = ?
326- ORDER BY fs.jaccard DESC
327- LIMIT ?
328- `, feedURL, feedURL, feedURL, limit)
329- if err != nil {
330- return nil, err
331- }
332- defer rows.Close()
333-
334- var feeds []*Feed
335- for rows.Next() {
336- f := &Feed{}
337- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
338- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
339- &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
340- return nil, err
341- }
342- feeds = append(feeds, f)
343- }
344- return feeds, rows.Err()
345-}
346-
347-type ArticleRecommendation struct {
348- ArticleID int64
349- Title string
350- URL string
351- FeedURL string
352- FeedTitle string
353- Author string
354- Summary string
355- Published sql.NullTime
356- Score float64
357-}
358-
359-func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
360- rows, err := db.QueryContext(ctx, `
361- SELECT a.id, a.title, COALESCE(a.url, ''), r.feed_url, COALESCE(f.title, ''),
362- COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, r.score
363- FROM user_article_recommendations r
364- JOIN articles a ON a.feed_url = r.feed_url AND a.url = r.article_url
365- LEFT JOIN feeds f ON f.feed_url = r.feed_url
366- WHERE r.user_did = ?
367- ORDER BY r.score DESC
368- LIMIT ?
369- `, userDID, limit)
370- if err != nil {
371- return nil, err
372- }
373- defer rows.Close()
374-
375- var recs []*ArticleRecommendation
376- for rows.Next() {
377- rec := &ArticleRecommendation{}
378- if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle,
379- &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil {
380- return nil, err
381- }
382- recs = append(recs, rec)
383- }
384- return recs, rows.Err()
385-}
modified internal/db/db.go +2 -0
@@ -152,6 +152,8 @@ var schema = []string{
152152 user_b TEXT NOT NULL REFERENCES users(did),
153153 jaccard REAL NOT NULL,
154154 common_feeds INTEGER NOT NULL,
155+ common_likes INTEGER NOT NULL DEFAULT 0,
156+ common_tags INTEGER NOT NULL DEFAULT 0,
155157 computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
156158 PRIMARY KEY (user_a, user_b),
157159 CHECK(user_a < user_b)
@@ -152,6 +152,8 @@ var schema = []string{
152 user_b TEXT NOT NULL REFERENCES users(did),152 user_b TEXT NOT NULL REFERENCES users(did),
153 jaccard REAL NOT NULL,153 jaccard REAL NOT NULL,
154 common_feeds INTEGER NOT NULL,154 common_feeds INTEGER NOT NULL,
155+ common_likes INTEGER NOT NULL DEFAULT 0,
156+ common_tags INTEGER NOT NULL DEFAULT 0,
155 computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,157 computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
156 PRIMARY KEY (user_a, user_b),158 PRIMARY KEY (user_a, user_b),
157 CHECK(user_a < user_b)159 CHECK(user_a < user_b)
modified internal/server/dashboard_handler.go +5 -3
@@ -19,12 +19,13 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
1919 articles = articles[:page.PageSize]
2020 }
2121
22- articleRecs, _ := s.db.GetArticleRecommendations(r.Context(), user.DID, 5)
23- peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
24- feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 5)
22+ articleRecs, _ := s.engine.GetArticleRecommendations(r.Context(), user.DID, 5)
23+ peopleRecs, _ := s.engine.GetPeopleRecommendations(r.Context(), user.DID, 5)
24+ feedRecs, _ := s.engine.GetFeedRecommendations(r.Context(), user.DID, 5)
2525
2626 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
2727 personalTrending, _ := s.db.ListTrendingArticlesForUser(r.Context(), user.DID, since, 5, 0)
28+ globalTrending, _ := s.db.ListTrendingArticles(r.Context(), since, 10, 0)
2829
2930 s.render(w, r, "dashboard.html", map[string]any{
3031 "User": user,
@@ -35,6 +36,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
3536 "FeedRecommendations": feedRecs,
3637 "PeopleRecommendations": peopleRecs,
3738 "PersonalTrending": personalTrending,
39+ "GlobalTrending": globalTrending,
3840 "Page": page,
3941 "BaseURL": "/dashboard",
4042 "QueryParams": map[string]string{},
@@ -19,12 +19,13 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
19 articles = articles[:page.PageSize]19 articles = articles[:page.PageSize]
20 }20 }
21 21
22- articleRecs, _ := s.db.GetArticleRecommendations(r.Context(), user.DID, 5)22+ articleRecs, _ := s.engine.GetArticleRecommendations(r.Context(), user.DID, 5)
23- peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)23+ peopleRecs, _ := s.engine.GetPeopleRecommendations(r.Context(), user.DID, 5)
24- feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 5)24+ feedRecs, _ := s.engine.GetFeedRecommendations(r.Context(), user.DID, 5)
25 25
26 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)26 since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
27 personalTrending, _ := s.db.ListTrendingArticlesForUser(r.Context(), user.DID, since, 5, 0)27 personalTrending, _ := s.db.ListTrendingArticlesForUser(r.Context(), user.DID, since, 5, 0)
28+ globalTrending, _ := s.db.ListTrendingArticles(r.Context(), since, 10, 0)
28 29
29 s.render(w, r, "dashboard.html", map[string]any{30 s.render(w, r, "dashboard.html", map[string]any{
30 "User": user,31 "User": user,
@@ -35,6 +36,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
35 "FeedRecommendations": feedRecs,36 "FeedRecommendations": feedRecs,
36 "PeopleRecommendations": peopleRecs,37 "PeopleRecommendations": peopleRecs,
37 "PersonalTrending": personalTrending,38 "PersonalTrending": personalTrending,
39+ "GlobalTrending": globalTrending,
38 "Page": page,40 "Page": page,
39 "BaseURL": "/dashboard",41 "BaseURL": "/dashboard",
40 "QueryParams": map[string]string{},42 "QueryParams": map[string]string{},
modified internal/server/feeds_handler.go +2 -2
@@ -26,8 +26,8 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
2626 }
2727
2828 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)
29- feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)
30- peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
29+ feedRecs, _ := s.engine.GetFeedRecommendations(r.Context(), user.DID, 10)
30+ peopleRecs, _ := s.engine.GetPeopleRecommendations(r.Context(), user.DID, 5)
3131 deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)
3232
3333 categories, _ := s.db.GetCategories(r.Context(), user.DID)
@@ -26,8 +26,8 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
26 }26 }
27 27
28 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)28 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)
29- feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)29+ feedRecs, _ := s.engine.GetFeedRecommendations(r.Context(), user.DID, 10)
30- peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)30+ peopleRecs, _ := s.engine.GetPeopleRecommendations(r.Context(), user.DID, 5)
31 deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)31 deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)
32 32
33 categories, _ := s.db.GetCategories(r.Context(), user.DID)33 categories, _ := s.db.GetCategories(r.Context(), user.DID)
modified internal/tmpl/articles.html +1 -1
@@ -1,5 +1,5 @@
11 {{define "articles.html"}}
2- <div hx-get="/articles/new-count?since={{.Now.Unix}}&return=/articles" hx-trigger="every 30s" hx-swap="beforebegin" hx-target="#article-list"></div>
2+ <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/articles" hx-trigger="every 30s" hx-swap="innerHTML"></div>
33 <div class="flex items-center justify-between mb-2">
44 <h1 class="text-2xl font-bold text-spot-text">Articles</h1>
55 <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
@@ -1,5 +1,5 @@
1 {{define "articles.html"}}1 {{define "articles.html"}}
2- <div hx-get="/articles/new-count?since={{.Now.Unix}}&return=/articles" hx-trigger="every 30s" hx-swap="beforebegin" hx-target="#article-list"></div>2+ <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/articles" hx-trigger="every 30s" hx-swap="innerHTML"></div>
3 <div class="flex items-center justify-between mb-2">3 <div class="flex items-center justify-between mb-2">
4 <h1 class="text-2xl font-bold text-spot-text">Articles</h1>4 <h1 class="text-2xl font-bold text-spot-text">Articles</h1>
5 <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">5 <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
modified internal/tmpl/base.html +1 -1
@@ -191,7 +191,7 @@
191191 </div>
192192 </div>
193193 <div class="text-right">
194- <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} julien.rbrt.fr</div>
194+ <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr">julien.rbrt.fr</a></div>
195195 <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe &#127466;&#127482;</div>
196196 </div>
197197 </div>
@@ -191,7 +191,7 @@
191 </div>191 </div>
192 </div>192 </div>
193 <div class="text-right">193 <div class="text-right">
194- <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} julien.rbrt.fr</div>194+ <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr">julien.rbrt.fr</a></div>
195 <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe &#127466;&#127482;</div>195 <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe &#127466;&#127482;</div>
196 </div>196 </div>
197 </div>197 </div>
modified internal/tmpl/dashboard.html +42 -7
@@ -7,7 +7,7 @@
77 </div>
88 </div>
99 {{if eq .SubscriptionCount 0}}
10-<div class="bg-spot-surface rounded-xl py-16 px-6 text-center mb-6">
10+<div class="bg-spot-surface rounded-xl py-12 px-6 text-center mb-6">
1111 <div class="w-16 h-16 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
1212 <svg class="w-8 h-8 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
1313 </div>
@@ -18,10 +18,45 @@
1818 Add feeds
1919 </a>
2020 </div>
21+
22+{{if .GlobalTrending}}
23+<div class="mb-6">
24+ <div class="flex items-center justify-between mb-3">
25+ <h2 class="text-lg font-semibold text-spot-text">Trending</h2>
26+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
27+ </div>
28+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
29+ {{range .GlobalTrending}}
30+ <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-xl p-4 hover:bg-spot-hover-50 transition">
31+ <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div>
32+ <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary">
33+ {{if .Author}}<span>{{.Author}}</span>{{end}}
34+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{end}}</span>
35+ </div>
36+ <div class="flex items-center gap-3 mt-2 text-xs text-spot-secondary">
37+ <span class="inline-flex items-center gap-1"><svg class="w-4 h-4 text-spot-muted" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">{{template "icon-heart"}}</svg> {{.LikeCount}}</span>
38+ <span class="text-spot-muted">{{.AnnotationCount}} notes</span>
39+ </div>
40+ </a>
41+ {{end}}
42+ </div>
43+</div>
44+{{end}}
45+
46+{{if .FeedRecommendations}}
47+<div>
48+ <h2 class="text-lg font-semibold text-spot-text mb-3">Popular feeds to get started</h2>
49+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
50+ {{range .FeedRecommendations}}
51+ {{template "recommendation-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
52+ {{end}}
53+ </div>
54+</div>
55+{{end}}
2156 {{else}}
2257 <p class="text-sm text-spot-secondary mb-6">Your personalized feed, based on your social graph.</p>
2358
24-<div hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="beforebegin" hx-target="#article-list"></div>
59+<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
2560
2661 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
2762 <div class="lg:col-span-2">
@@ -109,12 +144,12 @@
109144 <div class="space-y-3">
110145 {{range .PeopleRecommendations}}
111146 <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
112- {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
147+ {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}}
113148 <div class="min-w-0 flex-1">
114- <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.handle}}</a>
115- {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
149+ <a href="/profile/{{.Handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a>
150+ {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}}
116151 </div>
117- <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>
152+ <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span>
118153 </div>
119154 {{end}}
120155 </div>
@@ -126,7 +161,7 @@
126161 <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
127162 <div class="space-y-3">
128163 {{range .FeedRecommendations}}
129- {{template "recommendation-card.html" (dict "title" .title "feed_url" .feed_url "description" .description "favicon_url" .favicon_url "subscriber_count" .subscriber_count "CSRFToken" $.CSRFToken)}}
164+ {{template "recommendation-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
130165 {{end}}
131166 </div>
132167 </div>
@@ -7,7 +7,7 @@
7 </div>7 </div>
8 </div>8 </div>
9 {{if eq .SubscriptionCount 0}}9 {{if eq .SubscriptionCount 0}}
10-<div class="bg-spot-surface rounded-xl py-16 px-6 text-center mb-6">10+<div class="bg-spot-surface rounded-xl py-12 px-6 text-center mb-6">
11 <div class="w-16 h-16 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">11 <div class="w-16 h-16 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
12 <svg class="w-8 h-8 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>12 <svg class="w-8 h-8 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
13 </div>13 </div>
@@ -18,10 +18,45 @@
18 Add feeds18 Add feeds
19 </a>19 </a>
20 </div>20 </div>
21+
22+{{if .GlobalTrending}}
23+<div class="mb-6">
24+ <div class="flex items-center justify-between mb-3">
25+ <h2 class="text-lg font-semibold text-spot-text">Trending</h2>
26+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
27+ </div>
28+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
29+ {{range .GlobalTrending}}
30+ <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-xl p-4 hover:bg-spot-hover-50 transition">
31+ <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div>
32+ <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary">
33+ {{if .Author}}<span>{{.Author}}</span>{{end}}
34+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{end}}</span>
35+ </div>
36+ <div class="flex items-center gap-3 mt-2 text-xs text-spot-secondary">
37+ <span class="inline-flex items-center gap-1"><svg class="w-4 h-4 text-spot-muted" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">{{template "icon-heart"}}</svg> {{.LikeCount}}</span>
38+ <span class="text-spot-muted">{{.AnnotationCount}} notes</span>
39+ </div>
40+ </a>
41+ {{end}}
42+ </div>
43+</div>
44+{{end}}
45+
46+{{if .FeedRecommendations}}
47+<div>
48+ <h2 class="text-lg font-semibold text-spot-text mb-3">Popular feeds to get started</h2>
49+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
50+ {{range .FeedRecommendations}}
51+ {{template "recommendation-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
52+ {{end}}
53+ </div>
54+</div>
55+{{end}}
21 {{else}}56 {{else}}
22 <p class="text-sm text-spot-secondary mb-6">Your personalized feed, based on your social graph.</p>57 <p class="text-sm text-spot-secondary mb-6">Your personalized feed, based on your social graph.</p>
23 58
24-<div hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="beforebegin" hx-target="#article-list"></div>59+<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
25 60
26 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">61 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
27 <div class="lg:col-span-2">62 <div class="lg:col-span-2">
@@ -109,12 +144,12 @@
109 <div class="space-y-3">144 <div class="space-y-3">
110 {{range .PeopleRecommendations}}145 {{range .PeopleRecommendations}}
111 <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">146 <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
112- {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}147+ {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}}
113 <div class="min-w-0 flex-1">148 <div class="min-w-0 flex-1">
114- <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.handle}}</a>149+ <a href="/profile/{{.Handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a>
115- {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}150+ {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}}
116 </div>151 </div>
117- <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>152+ <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span>
118 </div>153 </div>
119 {{end}}154 {{end}}
120 </div>155 </div>
@@ -126,7 +161,7 @@
126 <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>161 <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
127 <div class="space-y-3">162 <div class="space-y-3">
128 {{range .FeedRecommendations}}163 {{range .FeedRecommendations}}
129- {{template "recommendation-card.html" (dict "title" .title "feed_url" .feed_url "description" .description "favicon_url" .favicon_url "subscriber_count" .subscriber_count "CSRFToken" $.CSRFToken)}}164+ {{template "recommendation-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
130 {{end}}165 {{end}}
131 </div>166 </div>
132 </div>167 </div>
modified internal/tmpl/feeds.html +5 -5
@@ -42,7 +42,7 @@
4242 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recommended feeds</h2>
4343 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
4444 {{range .FeedRecommendations}}
45- {{template "recommendation-card.html" (dict "title" .title "feed_url" .feed_url "description" .description "favicon_url" .favicon_url "subscriber_count" .subscriber_count "CSRFToken" $.CSRFToken)}}
45+ {{template "recommendation-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
4646 {{end}}
4747 </div>
4848 </div>
@@ -141,12 +141,12 @@
141141 <div class="space-y-3">
142142 {{range .PeopleRecommendations}}
143143 <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
144- {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
144+ {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}}
145145 <div class="min-w-0 flex-1">
146- <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.handle}}</a>
147- {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
146+ <a href="/profile/{{.Handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a>
147+ {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}}
148148 </div>
149- <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>
149+ <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span>
150150 </div>
151151 {{end}}
152152 </div>
@@ -42,7 +42,7 @@
42 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recommended feeds</h2>42 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recommended feeds</h2>
43 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">43 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
44 {{range .FeedRecommendations}}44 {{range .FeedRecommendations}}
45- {{template "recommendation-card.html" (dict "title" .title "feed_url" .feed_url "description" .description "favicon_url" .favicon_url "subscriber_count" .subscriber_count "CSRFToken" $.CSRFToken)}}45+ {{template "recommendation-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
46 {{end}}46 {{end}}
47 </div>47 </div>
48 </div>48 </div>
@@ -141,12 +141,12 @@
141 <div class="space-y-3">141 <div class="space-y-3">
142 {{range .PeopleRecommendations}}142 {{range .PeopleRecommendations}}
143 <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">143 <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
144- {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}144+ {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}}
145 <div class="min-w-0 flex-1">145 <div class="min-w-0 flex-1">
146- <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.handle}}</a>146+ <a href="/profile/{{.Handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a>
147- {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}147+ {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}}
148 </div>148 </div>
149- <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>149+ <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span>
150 </div>150 </div>
151 {{end}}151 {{end}}
152 </div>152 </div>