Refactor recommendation engine, improve dashboardUnverified
9255228 parent: 6bb8ed5 modified
internal/cluster/jaccard.go +363 -35 | @@ -3,14 +3,38 @@ package cluster | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | + "fmt" | |
| 6 | 7 | "log/slog" |
| 7 | 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 | 29 | type Engine struct { |
| 11 | 30 | db *sql.DB |
| 12 | 31 | logger *slog.Logger |
| 13 | 32 | 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 | 40 | func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { |
| @@ -24,17 +48,17 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { | ||
| 24 | 48 | return err |
| 25 | 49 | } |
| 26 | 50 | |
| 27 | - _, err = tx.ExecContext(ctx, ` | |
| 51 | + query := fmt.Sprintf(` | |
| 28 | 52 | INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score) |
| 29 | 53 | SELECT targets.target, l.feed_url, l.article_url, SUM(targets.jaccard) AS score |
| 30 | 54 | FROM ( |
| 31 | 55 | SELECT us.user_a AS target, us.user_b AS peer, us.jaccard |
| 32 | 56 | FROM user_similarity us |
| 33 | - WHERE us.jaccard > 0.2 | |
| 57 | + WHERE us.jaccard > %g | |
| 34 | 58 | UNION ALL |
| 35 | 59 | SELECT us.user_b AS target, us.user_a AS peer, us.jaccard |
| 36 | 60 | FROM user_similarity us |
| 37 | - WHERE us.jaccard > 0.2 | |
| 61 | + WHERE us.jaccard > %g | |
| 38 | 62 | ) targets |
| 39 | 63 | JOIN likes l ON l.author_did = targets.peer |
| 40 | 64 | WHERE NOT EXISTS ( |
| @@ -46,8 +70,9 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { | ||
| 46 | 70 | GROUP BY targets.target, l.feed_url, l.article_url |
| 47 | 71 | HAVING COUNT(*) > 0 |
| 48 | 72 | 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 { | |
| 51 | 76 | 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 | 98 | func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error { |
| 78 | 99 | tx, err := e.db.BeginTx(ctx, nil) |
| 79 | 100 | if err != nil { |
| @@ -102,10 +123,104 @@ func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error { | ||
| 102 | 123 | 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 | 130 | e.logger.Info("feed similarity computed") |
| 106 | 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 | 224 | func (e *Engine) ComputeUserSimilarity(ctx context.Context) error { |
| 110 | 225 | tx, err := e.db.BeginTx(ctx, nil) |
| 111 | 226 | if err != nil { |
| @@ -137,20 +252,122 @@ func (e *Engine) ComputeUserSimilarity(ctx context.Context) error { | ||
| 137 | 252 | 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 | 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 | 358 | SELECT |
| 143 | 359 | MIN(f.user_did, f.target_did), |
| 144 | 360 | MAX(f.user_did, f.target_did), |
| 145 | - 0.5, | |
| 146 | - 0 | |
| 361 | + %g, | |
| 362 | + 0, 0, 0 | |
| 147 | 363 | FROM follows f |
| 148 | 364 | WHERE f.user_did != f.target_did |
| 149 | 365 | GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did) |
| 150 | 366 | 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 { | |
| 154 | 371 | return err |
| 155 | 372 | } |
| 156 | 373 | |
| @@ -190,20 +407,128 @@ func (e *Engine) ComputeUserSimilarityForUser(ctx context.Context, userDID strin | ||
| 190 | 407 | 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 | 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 | 519 | SELECT |
| 196 | 520 | MIN(?, f.target_did), |
| 197 | 521 | MAX(?, f.target_did), |
| 198 | - 0.5, | |
| 199 | - 0 | |
| 522 | + %g, | |
| 523 | + 0, 0, 0 | |
| 200 | 524 | FROM follows f |
| 201 | 525 | WHERE f.user_did = ? AND f.target_did != ? |
| 202 | 526 | GROUP BY MIN(?, f.target_did), MAX(?, f.target_did) |
| 203 | 527 | 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 { | |
| 207 | 532 | return err |
| 208 | 533 | } |
| 209 | 534 | |
| @@ -222,7 +547,7 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri | ||
| 222 | 547 | return err |
| 223 | 548 | } |
| 224 | 549 | |
| 225 | - _, err = tx.ExecContext(ctx, ` | |
| 550 | + recQuery := fmt.Sprintf(` | |
| 226 | 551 | INSERT INTO user_feed_recommendations (user_did, feed_url, score) |
| 227 | 552 | SELECT ?, s.feed_url, SUM(us.jaccard) AS score |
| 228 | 553 | FROM user_similarity us |
| @@ -231,12 +556,13 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri | ||
| 231 | 556 | ELSE us.user_a |
| 232 | 557 | END |
| 233 | 558 | WHERE (us.user_a = ? OR us.user_b = ?) |
| 234 | - AND us.jaccard > 0.2 | |
| 559 | + AND us.jaccard > %g | |
| 235 | 560 | AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) |
| 236 | 561 | GROUP BY s.feed_url |
| 237 | 562 | 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 { | |
| 240 | 566 | return err |
| 241 | 567 | } |
| 242 | 568 | |
| @@ -263,15 +589,15 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD | ||
| 263 | 589 | return err |
| 264 | 590 | } |
| 265 | 591 | |
| 266 | - _, err = tx.ExecContext(ctx, ` | |
| 592 | + artQuery := fmt.Sprintf(` | |
| 267 | 593 | INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score) |
| 268 | 594 | SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score |
| 269 | 595 | FROM ( |
| 270 | 596 | 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 | |
| 272 | 598 | UNION ALL |
| 273 | 599 | 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 | |
| 275 | 601 | ) us |
| 276 | 602 | JOIN likes l ON l.author_did = us.peer |
| 277 | 603 | WHERE NOT EXISTS ( |
| @@ -283,8 +609,9 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD | ||
| 283 | 609 | GROUP BY l.feed_url, l.article_url |
| 284 | 610 | HAVING COUNT(*) > 0 |
| 285 | 611 | 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 { | |
| 288 | 615 | return err |
| 289 | 616 | } |
| 290 | 617 | |
| @@ -302,14 +629,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error { | ||
| 302 | 629 | return err |
| 303 | 630 | } |
| 304 | 631 | |
| 305 | - _, err = tx.ExecContext(ctx, ` | |
| 632 | + recQuery := fmt.Sprintf(` | |
| 306 | 633 | INSERT INTO user_feed_recommendations (user_did, feed_url, score) |
| 307 | 634 | SELECT target, feed_url, SUM(jaccard) AS score |
| 308 | 635 | FROM ( |
| 309 | 636 | SELECT us.user_a AS target, s.feed_url, us.jaccard |
| 310 | 637 | FROM user_similarity us |
| 311 | 638 | JOIN subscriptions s ON s.user_did = us.user_b |
| 312 | - WHERE us.jaccard > 0.2 | |
| 639 | + WHERE us.jaccard > %g | |
| 313 | 640 | AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a) |
| 314 | 641 | |
| 315 | 642 | UNION ALL |
| @@ -317,13 +644,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error { | ||
| 317 | 644 | SELECT us.user_b AS target, s.feed_url, us.jaccard |
| 318 | 645 | FROM user_similarity us |
| 319 | 646 | JOIN subscriptions s ON s.user_did = us.user_a |
| 320 | - WHERE us.jaccard > 0.2 | |
| 647 | + WHERE us.jaccard > %g | |
| 321 | 648 | AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b) |
| 322 | 649 | ) |
| 323 | 650 | GROUP BY target, feed_url |
| 324 | 651 | 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 { | |
| 327 | 655 | return err |
| 328 | 656 | } |
| 329 | 657 | |
| @@ -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.DB | 30 | db *sql.DB |
| 12 | logger *slog.Logger | 31 | logger *slog.Logger |
| 13 | mu sync.Mutex | 32 | 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 err | 48 | 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 score | 53 | 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.jaccard | 55 | SELECT us.user_a AS target, us.user_b AS peer, us.jaccard |
| 32 | FROM user_similarity us | 56 | FROM user_similarity us |
| 33 | - WHERE us.jaccard > 0.2 | 57 | + WHERE us.jaccard > %g |
| 34 | UNION ALL | 58 | UNION ALL |
| 35 | SELECT us.user_b AS target, us.user_a AS peer, us.jaccard | 59 | SELECT us.user_b AS target, us.user_a AS peer, us.jaccard |
| 36 | FROM user_similarity us | 60 | FROM user_similarity us |
| 37 | - WHERE us.jaccard > 0.2 | 61 | + WHERE us.jaccard > %g |
| 38 | ) targets | 62 | ) targets |
| 39 | JOIN likes l ON l.author_did = targets.peer | 63 | 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_url | 70 | GROUP BY targets.target, l.feed_url, l.article_url |
| 47 | HAVING COUNT(*) > 0 | 71 | HAVING COUNT(*) > 0 |
| 48 | ORDER BY score DESC | 72 | 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 err | 76 | 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 err | 123 | 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 err | 252 | 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 | SELECT | 358 | 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 | - 0 | 362 | + 0, 0, 0 |
| 147 | FROM follows f | 363 | FROM follows f |
| 148 | WHERE f.user_did != f.target_did | 364 | 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 SET | 366 | ON CONFLICT(user_a, user_b) DO UPDATE SET |
| 151 | - jaccard = jaccard + 0.5 | 367 | + 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 err | 371 | return err |
| 155 | } | 372 | } |
| 156 | 373 | ||
| @@ -190,20 +407,128 @@ func (e *Engine) ComputeUserSimilarityForUser(ctx context.Context, userDID strin | |||
| 190 | return err | 407 | 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 | SELECT | 519 | 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 | - 0 | 523 | + 0, 0, 0 |
| 200 | FROM follows f | 524 | 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 SET | 527 | ON CONFLICT(user_a, user_b) DO UPDATE SET |
| 204 | - jaccard = jaccard + 0.5 | 528 | + 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 err | 532 | return err |
| 208 | } | 533 | } |
| 209 | 534 | ||
| @@ -222,7 +547,7 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri | |||
| 222 | return err | 547 | 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 score | 552 | SELECT ?, s.feed_url, SUM(us.jaccard) AS score |
| 228 | FROM user_similarity us | 553 | FROM user_similarity us |
| @@ -231,12 +556,13 @@ func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID stri | |||
| 231 | ELSE us.user_a | 556 | ELSE us.user_a |
| 232 | END | 557 | END |
| 233 | WHERE (us.user_a = ? OR us.user_b = ?) | 558 | WHERE (us.user_a = ? OR us.user_b = ?) |
| 234 | - AND us.jaccard > 0.2 | 559 | + 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_url | 561 | GROUP BY s.feed_url |
| 237 | ORDER BY score DESC | 562 | 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 err | 566 | return err |
| 241 | } | 567 | } |
| 242 | 568 | ||
| @@ -263,15 +589,15 @@ func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userD | |||
| 263 | return err | 589 | 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 score | 594 | SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score |
| 269 | FROM ( | 595 | FROM ( |
| 270 | SELECT us.user_b AS peer, us.jaccard | 596 | 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 |
| 272 | UNION ALL | 598 | UNION ALL |
| 273 | SELECT us.user_a AS peer, us.jaccard | 599 | 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 |
| 275 | ) us | 601 | ) us |
| 276 | JOIN likes l ON l.author_did = us.peer | 602 | 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_url | 609 | GROUP BY l.feed_url, l.article_url |
| 284 | HAVING COUNT(*) > 0 | 610 | HAVING COUNT(*) > 0 |
| 285 | ORDER BY score DESC | 611 | 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 err | 615 | return err |
| 289 | } | 616 | } |
| 290 | 617 | ||
| @@ -302,14 +629,14 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error { | |||
| 302 | return err | 629 | 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 score | 634 | SELECT target, feed_url, SUM(jaccard) AS score |
| 308 | FROM ( | 635 | FROM ( |
| 309 | SELECT us.user_a AS target, s.feed_url, us.jaccard | 636 | SELECT us.user_a AS target, s.feed_url, us.jaccard |
| 310 | FROM user_similarity us | 637 | FROM user_similarity us |
| 311 | JOIN subscriptions s ON s.user_did = us.user_b | 638 | JOIN subscriptions s ON s.user_did = us.user_b |
| 312 | - WHERE us.jaccard > 0.2 | 639 | + 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 ALL | 642 | 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.jaccard | 644 | SELECT us.user_b AS target, s.feed_url, us.jaccard |
| 318 | FROM user_similarity us | 645 | FROM user_similarity us |
| 319 | JOIN subscriptions s ON s.user_did = us.user_a | 646 | JOIN subscriptions s ON s.user_did = us.user_a |
| 320 | - WHERE us.jaccard > 0.2 | 647 | + 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_url | 650 | GROUP BY target, feed_url |
| 324 | ORDER BY score DESC | 651 | 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 err | 655 | return err |
| 328 | } | 656 | } |
| 329 | 657 | ||
modified
internal/cluster/jaccard_test.go +101 -3 | @@ -110,7 +110,7 @@ func TestComputeRecommendations_GeneratesFeedRecsForNewUser(t *testing.T) { | ||
| 110 | 110 | |
| 111 | 111 | var found bool |
| 112 | 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 | 114 | found = true |
| 115 | 115 | } |
| 116 | 116 | } |
| @@ -136,7 +136,7 @@ func TestComputeRecommendations_NoSelfRecommendations(t *testing.T) { | ||
| 136 | 136 | "https://c.com/feed": true, |
| 137 | 137 | } |
| 138 | 138 | for _, r := range recs { |
| 139 | - assert.Assert(t, !subscribedFeeds[r["feed_url"].(string)], | |
| 139 | + assert.Assert(t, !subscribedFeeds[r.FeedURL], | |
| 140 | 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 | 258 | var found bool |
| 259 | 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 | 261 | found = true |
| 262 | 262 | } |
| 263 | 263 | } |
| @@ -305,3 +305,101 @@ func TestComputeRecommendationsForUser_MatchesFullCompute(t *testing.T) { | ||
| 305 | 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 | +} | |
| @@ -110,7 +110,7 @@ func TestComputeRecommendations_GeneratesFeedRecsForNewUser(t *testing.T) { | |||
| 110 | 110 | ||
| 111 | var found bool | 111 | 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 = true | 114 | 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 bool | 258 | 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 = true | 261 | 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 | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 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 | 51 | 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 | |
| 10 | 54 | FROM user_feed_recommendations r |
| 11 | 55 | JOIN feeds f ON f.feed_url = r.feed_url |
| 12 | 56 | WHERE r.user_did = ? |
| @@ -18,34 +62,29 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | ||
| 18 | 62 | } |
| 19 | 63 | defer rows.Close() |
| 20 | 64 | |
| 21 | - var results []map[string]any | |
| 65 | + var results []*FeedRecommendation | |
| 22 | 66 | 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 { | |
| 26 | 70 | return nil, err |
| 27 | 71 | } |
| 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) | |
| 36 | 73 | } |
| 37 | 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 | 78 | 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) | |
| 43 | 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 | 83 | 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 | 85 | ) sim |
| 48 | 86 | JOIN users u ON u.did = sim.peer_did |
| 87 | + WHERE u.handle IS NOT NULL AND u.handle != '' | |
| 49 | 88 | ORDER BY sim.jaccard DESC |
| 50 | 89 | LIMIT ? |
| 51 | 90 | `, userDID, userDID, limit) |
| @@ -54,29 +93,50 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l | ||
| 54 | 93 | } |
| 55 | 94 | defer rows.Close() |
| 56 | 95 | |
| 57 | - var results []map[string]any | |
| 96 | + var results []*PersonRecommendation | |
| 58 | 97 | 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 { | |
| 63 | 101 | return nil, err |
| 64 | 102 | } |
| 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) | |
| 73 | 104 | } |
| 74 | 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 | 137 | 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 | |
| 80 | 140 | FROM ( |
| 81 | 141 | SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ? |
| 82 | 142 | UNION ALL |
| @@ -91,21 +151,14 @@ func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) | ||
| 91 | 151 | } |
| 92 | 152 | defer rows.Close() |
| 93 | 153 | |
| 94 | - var results []map[string]any | |
| 154 | + var results []*SimilarFeed | |
| 95 | 155 | 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 { | |
| 99 | 159 | return nil, err |
| 100 | 160 | } |
| 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) | |
| 109 | 162 | } |
| 110 | 163 | return results, rows.Err() |
| 111 | 164 | } |
| @@ -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.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 | ||
| 10 | FROM user_feed_recommendations r | 54 | FROM user_feed_recommendations r |
| 11 | JOIN feeds f ON f.feed_url = r.feed_url | 55 | 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]any | 65 | + var results []*FeedRecommendation |
| 22 | for rows.Next() { | 66 | for rows.Next() { |
| 23 | - var feedURL, title, siteURL, description, feedType string | 67 | + rec := &FeedRecommendation{} |
| 24 | - var score float64 | 68 | + 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, err | 70 | 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_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) | ||
| 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 ALL | 83 | 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 | ) sim | 85 | ) sim |
| 48 | JOIN users u ON u.did = sim.peer_did | 86 | JOIN users u ON u.did = sim.peer_did |
| 87 | + WHERE u.handle IS NOT NULL AND u.handle != '' | ||
| 49 | ORDER BY sim.jaccard DESC | 88 | 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]any | 96 | + var results []*PersonRecommendation |
| 58 | for rows.Next() { | 97 | for rows.Next() { |
| 59 | - var did, handle, displayName, avatarURL string | 98 | + rec := &PersonRecommendation{} |
| 60 | - var jaccard float64 | 99 | + if err := rows.Scan(&rec.DID, &rec.Handle, &rec.DisplayName, &rec.AvatarURL, |
| 61 | - var commonFeeds int | 100 | + &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, err | 101 | 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.jaccard | 138 | + 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 ALL | 142 | 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]any | 154 | + var results []*SimilarFeed |
| 95 | for rows.Next() { | 155 | for rows.Next() { |
| 96 | - var peerURL, title, siteURL, description, feedType string | 156 | + rec := &SimilarFeed{} |
| 97 | - var jaccard float64 | 157 | + 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, err | 159 | 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{ | ||
| 152 | 152 | user_b TEXT NOT NULL REFERENCES users(did), |
| 153 | 153 | jaccard REAL NOT NULL, |
| 154 | 154 | common_feeds INTEGER NOT NULL, |
| 155 | + common_likes INTEGER NOT NULL DEFAULT 0, | |
| 156 | + common_tags INTEGER NOT NULL DEFAULT 0, | |
| 155 | 157 | computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 156 | 158 | PRIMARY KEY (user_a, user_b), |
| 157 | 159 | 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) { | ||
| 19 | 19 | articles = articles[:page.PageSize] |
| 20 | 20 | } |
| 21 | 21 | |
| 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) | |
| 25 | 25 | |
| 26 | 26 | since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) |
| 27 | 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 | 30 | s.render(w, r, "dashboard.html", map[string]any{ |
| 30 | 31 | "User": user, |
| @@ -35,6 +36,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 35 | 36 | "FeedRecommendations": feedRecs, |
| 36 | 37 | "PeopleRecommendations": peopleRecs, |
| 37 | 38 | "PersonalTrending": personalTrending, |
| 39 | + "GlobalTrending": globalTrending, | |
| 38 | 40 | "Page": page, |
| 39 | 41 | "BaseURL": "/dashboard", |
| 40 | 42 | "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) { | ||
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | 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) | |
| 31 | 31 | deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7) |
| 32 | 32 | |
| 33 | 33 | 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 @@ | ||
| 1 | 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 | 3 | <div class="flex items-center justify-between mb-2"> |
| 4 | 4 | <h1 class="text-2xl font-bold text-spot-text">Articles</h1> |
| 5 | 5 | <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 @@ | ||
| 191 | 191 | </div> |
| 192 | 192 | </div> |
| 193 | 193 | <div class="text-right"> |
| 194 | - <div class="text-xs text-spot-secondary">© {{now.Format "2006"}} julien.rbrt.fr</div> | |
| 194 | + <div class="text-xs text-spot-secondary">© {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr">julien.rbrt.fr</a></div> | |
| 195 | 195 | <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe 🇪🇺</div> |
| 196 | 196 | </div> |
| 197 | 197 | </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">© {{now.Format "2006"}} julien.rbrt.fr</div> | 194 | + <div class="text-xs text-spot-secondary">© {{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 🇪🇺</div> | 195 | <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe 🇪🇺</div> |
| 196 | </div> | 196 | </div> |
| 197 | </div> | 197 | </div> |
modified
internal/tmpl/dashboard.html +42 -7 | @@ -7,7 +7,7 @@ | ||
| 7 | 7 | </div> |
| 8 | 8 | </div> |
| 9 | 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 | 11 | <div class="w-16 h-16 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> |
| 12 | 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 | 13 | </div> |
| @@ -18,10 +18,45 @@ | ||
| 18 | 18 | Add feeds |
| 19 | 19 | </a> |
| 20 | 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 | 56 | {{else}} |
| 22 | 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 | 61 | <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| 27 | 62 | <div class="lg:col-span-2"> |
| @@ -109,12 +144,12 @@ | ||
| 109 | 144 | <div class="space-y-3"> |
| 110 | 145 | {{range .PeopleRecommendations}} |
| 111 | 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 | 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> | |
| 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}} | |
| 116 | 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 | 153 | </div> |
| 119 | 154 | {{end}} |
| 120 | 155 | </div> |
| @@ -126,7 +161,7 @@ | ||
| 126 | 161 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> |
| 127 | 162 | <div class="space-y-3"> |
| 128 | 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 | 165 | {{end}} |
| 131 | 166 | </div> |
| 132 | 167 | </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 feeds | 18 | 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 @@ | ||
| 42 | 42 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recommended feeds</h2> |
| 43 | 43 | <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> |
| 44 | 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 | 46 | {{end}} |
| 47 | 47 | </div> |
| 48 | 48 | </div> |
| @@ -141,12 +141,12 @@ | ||
| 141 | 141 | <div class="space-y-3"> |
| 142 | 142 | {{range .PeopleRecommendations}} |
| 143 | 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 | 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> | |
| 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}} | |
| 148 | 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 | 150 | </div> |
| 151 | 151 | {{end}} |
| 152 | 152 | </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> |