Refactor simplify helpers and methodsUnverified
2345d08 parent: 73f96db modified
internal/atproto/sync.go +5 -18 | @@ -50,20 +50,7 @@ func (s *Sync) Run(ctx context.Context, userDID string) error { | ||
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | func (s *Sync) listRecords(ctx context.Context, userDID, collection string) ([]Record, error) { |
| 53 | - var allRecords []Record | |
| 54 | - cursor := "" | |
| 55 | - for { | |
| 56 | - records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) | |
| 57 | - if err != nil { | |
| 58 | - return nil, err | |
| 59 | - } | |
| 60 | - allRecords = append(allRecords, records...) | |
| 61 | - if next == "" || len(records) == 0 { | |
| 62 | - break | |
| 63 | - } | |
| 64 | - cursor = next | |
| 65 | - } | |
| 66 | - return allRecords, nil | |
| 53 | + return listAllRecords(ctx, s.client, userDID, collection) | |
| 67 | 54 | } |
| 68 | 55 | |
| 69 | 56 | func (s *Sync) syncSubscriptions(ctx context.Context, userDID string) error { |
| @@ -148,7 +135,7 @@ func (s *Sync) syncLikes(ctx context.Context, userDID string) error { | ||
| 148 | 135 | continue |
| 149 | 136 | } |
| 150 | 137 | activeURIs[r.URI] = true |
| 151 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 138 | + t := parseRFC3339(rec.CreatedAt) | |
| 152 | 139 | likes = append(likes, &db.Like{ |
| 153 | 140 | URI: r.URI, |
| 154 | 141 | AuthorDID: userDID, |
| @@ -187,7 +174,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | ||
| 187 | 174 | continue |
| 188 | 175 | } |
| 189 | 176 | activeURIs[r.URI] = true |
| 190 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 177 | + t := parseRFC3339(rec.CreatedAt) | |
| 191 | 178 | a := &db.Annotation{ |
| 192 | 179 | URI: r.URI, |
| 193 | 180 | AuthorDID: userDID, |
| @@ -221,7 +208,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | ||
| 221 | 208 | feedURL = article.FeedURL |
| 222 | 209 | } |
| 223 | 210 | |
| 224 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 211 | + t := parseRFC3339(rec.CreatedAt) | |
| 225 | 212 | annotations = append(annotations, &db.Annotation{ |
| 226 | 213 | URI: r.URI, |
| 227 | 214 | AuthorDID: userDID, |
| @@ -259,7 +246,7 @@ func (s *Sync) syncFollows(ctx context.Context, userDID string) error { | ||
| 259 | 246 | continue |
| 260 | 247 | } |
| 261 | 248 | |
| 262 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 249 | + t := parseRFC3339(rec.CreatedAt) | |
| 263 | 250 | activeFollows[rec.Subject] = db.Follow{ |
| 264 | 251 | URI: db.NullStr(r.URI), |
| 265 | 252 | CID: db.NullStr(r.CID), |
| @@ -50,20 +50,7 @@ func (s *Sync) Run(ctx context.Context, userDID string) error { | |||
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | func (s *Sync) listRecords(ctx context.Context, userDID, collection string) ([]Record, error) { | 52 | func (s *Sync) listRecords(ctx context.Context, userDID, collection string) ([]Record, error) { |
| 53 | - var allRecords []Record | 53 | + return listAllRecords(ctx, s.client, userDID, collection) |
| 54 | - cursor := "" | ||
| 55 | - for { | ||
| 56 | - records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) | ||
| 57 | - if err != nil { | ||
| 58 | - return nil, err | ||
| 59 | - } | ||
| 60 | - allRecords = append(allRecords, records...) | ||
| 61 | - if next == "" || len(records) == 0 { | ||
| 62 | - break | ||
| 63 | - } | ||
| 64 | - cursor = next | ||
| 65 | - } | ||
| 66 | - return allRecords, nil | ||
| 67 | } | 54 | } |
| 68 | 55 | ||
| 69 | func (s *Sync) syncSubscriptions(ctx context.Context, userDID string) error { | 56 | func (s *Sync) syncSubscriptions(ctx context.Context, userDID string) error { |
| @@ -148,7 +135,7 @@ func (s *Sync) syncLikes(ctx context.Context, userDID string) error { | |||
| 148 | continue | 135 | continue |
| 149 | } | 136 | } |
| 150 | activeURIs[r.URI] = true | 137 | activeURIs[r.URI] = true |
| 151 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 138 | + t := parseRFC3339(rec.CreatedAt) |
| 152 | likes = append(likes, &db.Like{ | 139 | likes = append(likes, &db.Like{ |
| 153 | URI: r.URI, | 140 | URI: r.URI, |
| 154 | AuthorDID: userDID, | 141 | AuthorDID: userDID, |
| @@ -187,7 +174,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | |||
| 187 | continue | 174 | continue |
| 188 | } | 175 | } |
| 189 | activeURIs[r.URI] = true | 176 | activeURIs[r.URI] = true |
| 190 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 177 | + t := parseRFC3339(rec.CreatedAt) |
| 191 | a := &db.Annotation{ | 178 | a := &db.Annotation{ |
| 192 | URI: r.URI, | 179 | URI: r.URI, |
| 193 | AuthorDID: userDID, | 180 | AuthorDID: userDID, |
| @@ -221,7 +208,7 @@ func (s *Sync) syncAnnotations(ctx context.Context, userDID string) error { | |||
| 221 | feedURL = article.FeedURL | 208 | feedURL = article.FeedURL |
| 222 | } | 209 | } |
| 223 | 210 | ||
| 224 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 211 | + t := parseRFC3339(rec.CreatedAt) |
| 225 | annotations = append(annotations, &db.Annotation{ | 212 | annotations = append(annotations, &db.Annotation{ |
| 226 | URI: r.URI, | 213 | URI: r.URI, |
| 227 | AuthorDID: userDID, | 214 | AuthorDID: userDID, |
| @@ -259,7 +246,7 @@ func (s *Sync) syncFollows(ctx context.Context, userDID string) error { | |||
| 259 | continue | 246 | continue |
| 260 | } | 247 | } |
| 261 | 248 | ||
| 262 | - t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 249 | + t := parseRFC3339(rec.CreatedAt) |
| 263 | activeFollows[rec.Subject] = db.Follow{ | 250 | activeFollows[rec.Subject] = db.Follow{ |
| 264 | URI: db.NullStr(r.URI), | 251 | URI: db.NullStr(r.URI), |
| 265 | CID: db.NullStr(r.CID), | 252 | CID: db.NullStr(r.CID), |
modified
internal/cluster/dismiss.go +11 -15 | @@ -11,31 +11,27 @@ type Impression struct { | ||
| 11 | 11 | TargetID string |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | -func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error { | |
| 14 | +// Dismiss records that the user dismissed a recommendation. targetType is one | |
| 15 | +// of "feed", "article", "person". | |
| 16 | +func (e *Engine) Dismiss(ctx context.Context, userDID, targetType, targetID, reason string) error { | |
| 15 | 17 | _, err := e.db.ExecContext(ctx, ` |
| 16 | 18 | INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) |
| 17 | - VALUES (?, 'feed', ?, ?) | |
| 19 | + VALUES (?, ?, ?, ?) | |
| 18 | 20 | ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP |
| 19 | - `, userDID, feedURL, reason) | |
| 21 | + `, userDID, targetType, targetID, reason) | |
| 20 | 22 | return err |
| 21 | 23 | } |
| 22 | 24 | |
| 25 | +func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error { | |
| 26 | + return e.Dismiss(ctx, userDID, "feed", feedURL, reason) | |
| 27 | +} | |
| 28 | + | |
| 23 | 29 | func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error { |
| 24 | - _, err := e.db.ExecContext(ctx, ` | |
| 25 | - INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) | |
| 26 | - VALUES (?, 'article', ?, ?) | |
| 27 | - ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP | |
| 28 | - `, userDID, articleURL, reason) | |
| 29 | - return err | |
| 30 | + return e.Dismiss(ctx, userDID, "article", articleURL, reason) | |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | func (e *Engine) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error { |
| 33 | - _, err := e.db.ExecContext(ctx, ` | |
| 34 | - INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) | |
| 35 | - VALUES (?, 'person', ?, ?) | |
| 36 | - ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP | |
| 37 | - `, userDID, targetDID, reason) | |
| 38 | - return err | |
| 34 | + return e.Dismiss(ctx, userDID, "person", targetDID, reason) | |
| 39 | 35 | } |
| 40 | 36 | |
| 41 | 37 | func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error { |
| @@ -11,31 +11,27 @@ type Impression struct { | |||
| 11 | TargetID string | 11 | TargetID string |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | -func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error { | 14 | +// Dismiss records that the user dismissed a recommendation. targetType is one |
| 15 | +// of "feed", "article", "person". | ||
| 16 | +func (e *Engine) Dismiss(ctx context.Context, userDID, targetType, targetID, reason string) error { | ||
| 15 | _, err := e.db.ExecContext(ctx, ` | 17 | _, err := e.db.ExecContext(ctx, ` |
| 16 | INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) | 18 | INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) |
| 17 | - VALUES (?, 'feed', ?, ?) | 19 | + VALUES (?, ?, ?, ?) |
| 18 | ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP | 20 | ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP |
| 19 | - `, userDID, feedURL, reason) | 21 | + `, userDID, targetType, targetID, reason) |
| 20 | return err | 22 | return err |
| 21 | } | 23 | } |
| 22 | 24 | ||
| 25 | +func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error { | ||
| 26 | + return e.Dismiss(ctx, userDID, "feed", feedURL, reason) | ||
| 27 | +} | ||
| 28 | + | ||
| 23 | func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error { | 29 | func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error { |
| 24 | - _, err := e.db.ExecContext(ctx, ` | 30 | + return e.Dismiss(ctx, userDID, "article", articleURL, reason) |
| 25 | - INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) | ||
| 26 | - VALUES (?, 'article', ?, ?) | ||
| 27 | - ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP | ||
| 28 | - `, userDID, articleURL, reason) | ||
| 29 | - return err | ||
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | func (e *Engine) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error { | 33 | func (e *Engine) DismissPerson(ctx context.Context, userDID, targetDID, reason string) error { |
| 33 | - _, err := e.db.ExecContext(ctx, ` | 34 | + return e.Dismiss(ctx, userDID, "person", targetDID, reason) |
| 34 | - INSERT INTO main.dismissed_recommendations (user_did, target_type, target_id, reason) | ||
| 35 | - VALUES (?, 'person', ?, ?) | ||
| 36 | - ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP | ||
| 37 | - `, userDID, targetDID, reason) | ||
| 38 | - return err | ||
| 39 | } | 35 | } |
| 40 | 36 | ||
| 41 | func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error { | 37 | func (e *Engine) RecordImpressions(ctx context.Context, userDID string, impressions []Impression) error { |
modified
internal/cluster/scoring.go +31 -65 | @@ -4,7 +4,8 @@ import ( | ||
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | 6 | "fmt" |
| 7 | - "strings" | |
| 7 | + | |
| 8 | + "pkg.rbrt.fr/glean/internal/db" | |
| 8 | 9 | ) |
| 9 | 10 | |
| 10 | 11 | type FeedRecommendation struct { |
| @@ -234,83 +235,48 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID | ||
| 234 | 235 | return results, rows.Err() |
| 235 | 236 | } |
| 236 | 237 | |
| 237 | -func buildLangFilter(languages []string, prefix string) (string, []any) { | |
| 238 | - if len(languages) == 0 { | |
| 239 | - return "", nil | |
| 240 | - } | |
| 241 | - ph := make([]string, len(languages)) | |
| 242 | - args := make([]any, len(languages)) | |
| 243 | - for i, l := range languages { | |
| 244 | - ph[i] = "?" | |
| 245 | - args[i] = l | |
| 246 | - } | |
| 247 | - return "AND (" + prefix + "language IN (" + strings.Join(ph, ",") + ") OR " + prefix + "language = '')", args | |
| 248 | -} | |
| 249 | - | |
| 250 | -func normalizeFeedScores(recs []*FeedRecommendation) { | |
| 251 | - if len(recs) < 2 { | |
| 238 | +func normalizeScores[T any](items []T, getScore func(T) float64, setScore func(T, float64)) { | |
| 239 | + if len(items) < 2 { | |
| 252 | 240 | return |
| 253 | 241 | } |
| 254 | - min, max := recs[0].Score, recs[0].Score | |
| 255 | - for _, r := range recs[1:] { | |
| 256 | - if r.Score < min { | |
| 257 | - min = r.Score | |
| 242 | + smin, smax := getScore(items[0]), getScore(items[0]) | |
| 243 | + for i := range items[1:] { | |
| 244 | + s := getScore(items[i+1]) | |
| 245 | + if s < smin { | |
| 246 | + smin = s | |
| 258 | 247 | } |
| 259 | - if r.Score > max { | |
| 260 | - max = r.Score | |
| 248 | + if s > smax { | |
| 249 | + smax = s | |
| 261 | 250 | } |
| 262 | 251 | } |
| 263 | - if max == min { | |
| 252 | + if smax == smin { | |
| 264 | 253 | return |
| 265 | 254 | } |
| 266 | - span := max - min | |
| 267 | - for _, r := range recs { | |
| 268 | - r.Score = (r.Score - min) / span | |
| 255 | + span := smax - smin | |
| 256 | + for i := range items { | |
| 257 | + setScore(items[i], (getScore(items[i])-smin)/span) | |
| 269 | 258 | } |
| 270 | 259 | } |
| 271 | 260 | |
| 261 | +func normalizeFeedScores(recs []*FeedRecommendation) { | |
| 262 | + normalizeScores(recs, | |
| 263 | + func(r *FeedRecommendation) float64 { return r.Score }, | |
| 264 | + func(r *FeedRecommendation, v float64) { r.Score = v }, | |
| 265 | + ) | |
| 266 | +} | |
| 267 | + | |
| 272 | 268 | func normalizeArticleScores(recs []*ArticleRecommendation) { |
| 273 | - if len(recs) < 2 { | |
| 274 | - return | |
| 275 | - } | |
| 276 | - min, max := recs[0].Score, recs[0].Score | |
| 277 | - for _, r := range recs[1:] { | |
| 278 | - if r.Score < min { | |
| 279 | - min = r.Score | |
| 280 | - } | |
| 281 | - if r.Score > max { | |
| 282 | - max = r.Score | |
| 283 | - } | |
| 284 | - } | |
| 285 | - if max == min { | |
| 286 | - return | |
| 287 | - } | |
| 288 | - span := max - min | |
| 289 | - for _, r := range recs { | |
| 290 | - r.Score = (r.Score - min) / span | |
| 291 | - } | |
| 269 | + normalizeScores(recs, | |
| 270 | + func(r *ArticleRecommendation) float64 { return r.Score }, | |
| 271 | + func(r *ArticleRecommendation, v float64) { r.Score = v }, | |
| 272 | + ) | |
| 292 | 273 | } |
| 293 | 274 | |
| 294 | 275 | func normalizePersonScores(recs []*PersonRecommendation) { |
| 295 | - if len(recs) < 2 { | |
| 296 | - return | |
| 297 | - } | |
| 298 | - min, max := recs[0].Jaccard, recs[0].Jaccard | |
| 299 | - for _, r := range recs[1:] { | |
| 300 | - if r.Jaccard < min { | |
| 301 | - min = r.Jaccard | |
| 302 | - } | |
| 303 | - if r.Jaccard > max { | |
| 304 | - max = r.Jaccard | |
| 305 | - } | |
| 306 | - } | |
| 307 | - if max == min { | |
| 308 | - return | |
| 309 | - } | |
| 310 | - span := max - min | |
| 311 | - for _, r := range recs { | |
| 312 | - r.Jaccard = (r.Jaccard - min) / span | |
| 313 | - } | |
| 276 | + normalizeScores(recs, | |
| 277 | + func(r *PersonRecommendation) float64 { return r.Jaccard }, | |
| 278 | + func(r *PersonRecommendation, v float64) { r.Jaccard = v }, | |
| 279 | + ) | |
| 314 | 280 | } |
| 315 | 281 | |
| 316 | 282 | func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) { |
| @@ -422,7 +388,7 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | ||
| 422 | 388 | } |
| 423 | 389 | } |
| 424 | 390 | |
| 425 | - langFilter, langArgs := buildLangFilter(languages, "a.") | |
| 391 | + langFilter, langArgs := db.BuildLangFilter(languages, "a.") | |
| 426 | 392 | |
| 427 | 393 | query := fmt.Sprintf(` |
| 428 | 394 | WITH similar_users AS ( |
| @@ -4,7 +4,8 @@ import ( | |||
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | - "strings" | 7 | + |
| 8 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| 10 | type FeedRecommendation struct { | 11 | type FeedRecommendation struct { |
| @@ -234,83 +235,48 @@ func (e *Engine) ComputeFeedRecommendationsOnDemand(ctx context.Context, userDID | |||
| 234 | return results, rows.Err() | 235 | return results, rows.Err() |
| 235 | } | 236 | } |
| 236 | 237 | ||
| 237 | -func buildLangFilter(languages []string, prefix string) (string, []any) { | 238 | +func normalizeScores[T any](items []T, getScore func(T) float64, setScore func(T, float64)) { |
| 238 | - if len(languages) == 0 { | 239 | + if len(items) < 2 { |
| 239 | - return "", nil | ||
| 240 | - } | ||
| 241 | - ph := make([]string, len(languages)) | ||
| 242 | - args := make([]any, len(languages)) | ||
| 243 | - for i, l := range languages { | ||
| 244 | - ph[i] = "?" | ||
| 245 | - args[i] = l | ||
| 246 | - } | ||
| 247 | - return "AND (" + prefix + "language IN (" + strings.Join(ph, ",") + ") OR " + prefix + "language = '')", args | ||
| 248 | -} | ||
| 249 | - | ||
| 250 | -func normalizeFeedScores(recs []*FeedRecommendation) { | ||
| 251 | - if len(recs) < 2 { | ||
| 252 | return | 240 | return |
| 253 | } | 241 | } |
| 254 | - min, max := recs[0].Score, recs[0].Score | 242 | + smin, smax := getScore(items[0]), getScore(items[0]) |
| 255 | - for _, r := range recs[1:] { | 243 | + for i := range items[1:] { |
| 256 | - if r.Score < min { | 244 | + s := getScore(items[i+1]) |
| 257 | - min = r.Score | 245 | + if s < smin { |
| 246 | + smin = s | ||
| 258 | } | 247 | } |
| 259 | - if r.Score > max { | 248 | + if s > smax { |
| 260 | - max = r.Score | 249 | + smax = s |
| 261 | } | 250 | } |
| 262 | } | 251 | } |
| 263 | - if max == min { | 252 | + if smax == smin { |
| 264 | return | 253 | return |
| 265 | } | 254 | } |
| 266 | - span := max - min | 255 | + span := smax - smin |
| 267 | - for _, r := range recs { | 256 | + for i := range items { |
| 268 | - r.Score = (r.Score - min) / span | 257 | + setScore(items[i], (getScore(items[i])-smin)/span) |
| 269 | } | 258 | } |
| 270 | } | 259 | } |
| 271 | 260 | ||
| 261 | +func normalizeFeedScores(recs []*FeedRecommendation) { | ||
| 262 | + normalizeScores(recs, | ||
| 263 | + func(r *FeedRecommendation) float64 { return r.Score }, | ||
| 264 | + func(r *FeedRecommendation, v float64) { r.Score = v }, | ||
| 265 | + ) | ||
| 266 | +} | ||
| 267 | + | ||
| 272 | func normalizeArticleScores(recs []*ArticleRecommendation) { | 268 | func normalizeArticleScores(recs []*ArticleRecommendation) { |
| 273 | - if len(recs) < 2 { | 269 | + normalizeScores(recs, |
| 274 | - return | 270 | + func(r *ArticleRecommendation) float64 { return r.Score }, |
| 275 | - } | 271 | + func(r *ArticleRecommendation, v float64) { r.Score = v }, |
| 276 | - min, max := recs[0].Score, recs[0].Score | 272 | + ) |
| 277 | - for _, r := range recs[1:] { | ||
| 278 | - if r.Score < min { | ||
| 279 | - min = r.Score | ||
| 280 | - } | ||
| 281 | - if r.Score > max { | ||
| 282 | - max = r.Score | ||
| 283 | - } | ||
| 284 | - } | ||
| 285 | - if max == min { | ||
| 286 | - return | ||
| 287 | - } | ||
| 288 | - span := max - min | ||
| 289 | - for _, r := range recs { | ||
| 290 | - r.Score = (r.Score - min) / span | ||
| 291 | - } | ||
| 292 | } | 273 | } |
| 293 | 274 | ||
| 294 | func normalizePersonScores(recs []*PersonRecommendation) { | 275 | func normalizePersonScores(recs []*PersonRecommendation) { |
| 295 | - if len(recs) < 2 { | 276 | + normalizeScores(recs, |
| 296 | - return | 277 | + func(r *PersonRecommendation) float64 { return r.Jaccard }, |
| 297 | - } | 278 | + func(r *PersonRecommendation, v float64) { r.Jaccard = v }, |
| 298 | - min, max := recs[0].Jaccard, recs[0].Jaccard | 279 | + ) |
| 299 | - for _, r := range recs[1:] { | ||
| 300 | - if r.Jaccard < min { | ||
| 301 | - min = r.Jaccard | ||
| 302 | - } | ||
| 303 | - if r.Jaccard > max { | ||
| 304 | - max = r.Jaccard | ||
| 305 | - } | ||
| 306 | - } | ||
| 307 | - if max == min { | ||
| 308 | - return | ||
| 309 | - } | ||
| 310 | - span := max - min | ||
| 311 | - for _, r := range recs { | ||
| 312 | - r.Jaccard = (r.Jaccard - min) / span | ||
| 313 | - } | ||
| 314 | } | 280 | } |
| 315 | 281 | ||
| 316 | func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) { | 282 | func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) { |
| @@ -422,7 +388,7 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | |||
| 422 | } | 388 | } |
| 423 | } | 389 | } |
| 424 | 390 | ||
| 425 | - langFilter, langArgs := buildLangFilter(languages, "a.") | 391 | + langFilter, langArgs := db.BuildLangFilter(languages, "a.") |
| 426 | 392 | ||
| 427 | query := fmt.Sprintf(` | 393 | query := fmt.Sprintf(` |
| 428 | WITH similar_users AS ( | 394 | WITH similar_users AS ( |
modified
internal/db/social.go +2 -2 | @@ -9,7 +9,7 @@ import ( | ||
| 9 | 9 | |
| 10 | 10 | var ErrDuplicateLike = errors.New("already liked this article") |
| 11 | 11 | |
| 12 | -func buildLangFilter(languages []string, prefix string) (string, []any) { | |
| 12 | +func BuildLangFilter(languages []string, prefix string) (string, []any) { | |
| 13 | 13 | if len(languages) == 0 { |
| 14 | 14 | return "", nil |
| 15 | 15 | } |
| @@ -381,7 +381,7 @@ type TrendingItem struct { | ||
| 381 | 381 | } |
| 382 | 382 | |
| 383 | 383 | func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, languages []string, limit, offset int) ([]*TrendingItem, error) { |
| 384 | - langFilter, langArgs := buildLangFilter(languages, "ar.") | |
| 384 | + langFilter, langArgs := BuildLangFilter(languages, "ar.") | |
| 385 | 385 | |
| 386 | 386 | rows, err := s.db.QueryContext(ctx, ` |
| 387 | 387 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), |
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | var ErrDuplicateLike = errors.New("already liked this article") | 10 | var ErrDuplicateLike = errors.New("already liked this article") |
| 11 | 11 | ||
| 12 | -func buildLangFilter(languages []string, prefix string) (string, []any) { | 12 | +func BuildLangFilter(languages []string, prefix string) (string, []any) { |
| 13 | if len(languages) == 0 { | 13 | if len(languages) == 0 { |
| 14 | return "", nil | 14 | return "", nil |
| 15 | } | 15 | } |
| @@ -381,7 +381,7 @@ type TrendingItem struct { | |||
| 381 | } | 381 | } |
| 382 | 382 | ||
| 383 | func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, languages []string, limit, offset int) ([]*TrendingItem, error) { | 383 | func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, languages []string, limit, offset int) ([]*TrendingItem, error) { |
| 384 | - langFilter, langArgs := buildLangFilter(languages, "ar.") | 384 | + langFilter, langArgs := BuildLangFilter(languages, "ar.") |
| 385 | 385 | ||
| 386 | rows, err := s.db.QueryContext(ctx, ` | 386 | rows, err := s.db.QueryContext(ctx, ` |
| 387 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), | 387 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), |
modified
internal/langdetect/langdetect.go +9 -5 | @@ -45,13 +45,17 @@ var knownLanguages = []Language{ | ||
| 45 | 45 | {"vi", "Vietnamese"}, |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | -func IsKnown(code string) bool { | |
| 48 | +var knownSet map[string]bool | |
| 49 | + | |
| 50 | +func init() { | |
| 51 | + knownSet = make(map[string]bool, len(knownLanguages)) | |
| 49 | 52 | for _, l := range knownLanguages { |
| 50 | - if l.Code == code { | |
| 51 | - return true | |
| 52 | - } | |
| 53 | + knownSet[l.Code] = true | |
| 53 | 54 | } |
| 54 | - return false | |
| 55 | +} | |
| 56 | + | |
| 57 | +func IsKnown(code string) bool { | |
| 58 | + return knownSet[code] | |
| 55 | 59 | } |
| 56 | 60 | |
| 57 | 61 | func KnownLanguages() []Language { |
| @@ -45,13 +45,17 @@ var knownLanguages = []Language{ | |||
| 45 | {"vi", "Vietnamese"}, | 45 | {"vi", "Vietnamese"}, |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | -func IsKnown(code string) bool { | 48 | +var knownSet map[string]bool |
| 49 | + | ||
| 50 | +func init() { | ||
| 51 | + knownSet = make(map[string]bool, len(knownLanguages)) | ||
| 49 | for _, l := range knownLanguages { | 52 | for _, l := range knownLanguages { |
| 50 | - if l.Code == code { | 53 | + knownSet[l.Code] = true |
| 51 | - return true | ||
| 52 | - } | ||
| 53 | } | 54 | } |
| 54 | - return false | 55 | +} |
| 56 | + | ||
| 57 | +func IsKnown(code string) bool { | ||
| 58 | + return knownSet[code] | ||
| 55 | } | 59 | } |
| 56 | 60 | ||
| 57 | func KnownLanguages() []Language { | 61 | func KnownLanguages() []Language { |
modified
internal/server/feeds_handler.go +8 -13 | @@ -2,7 +2,6 @@ package server | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | - "database/sql" | |
| 6 | 5 | "errors" |
| 7 | 6 | "net/http" |
| 8 | 7 | "net/url" |
| @@ -200,11 +199,11 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 200 | 199 | |
| 201 | 200 | f := &db.Feed{ |
| 202 | 201 | FeedURL: feedURL, |
| 203 | - Title: nullString(feedTitle), | |
| 204 | - SiteURL: nullString(result.Feed.SiteURL), | |
| 205 | - Description: nullString(result.Feed.Description), | |
| 206 | - FeedType: nullString(result.Feed.Type), | |
| 207 | - FaviconURL: nullString(faviconURL), | |
| 202 | + Title: db.NullStr(feedTitle), | |
| 203 | + SiteURL: db.NullStr(result.Feed.SiteURL), | |
| 204 | + Description: db.NullStr(result.Feed.Description), | |
| 205 | + FeedType: db.NullStr(result.Feed.Type), | |
| 206 | + FaviconURL: db.NullStr(faviconURL), | |
| 208 | 207 | } |
| 209 | 208 | if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil { |
| 210 | 209 | s.logger.Error("failed to upsert feed", "error", err) |
| @@ -351,9 +350,9 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | ||
| 351 | 350 | for _, fu := range feedURLs { |
| 352 | 351 | f := &db.Feed{ |
| 353 | 352 | FeedURL: fu.URL, |
| 354 | - Title: nullString(fu.Title), | |
| 355 | - SiteURL: nullString(fu.SiteURL), | |
| 356 | - Description: nullString(fu.Description), | |
| 353 | + Title: db.NullStr(fu.Title), | |
| 354 | + SiteURL: db.NullStr(fu.SiteURL), | |
| 355 | + Description: db.NullStr(fu.Description), | |
| 357 | 356 | } |
| 358 | 357 | if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil { |
| 359 | 358 | s.logger.Error("failed to upsert feed", "error", upsertErr) |
| @@ -524,7 +523,3 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { | ||
| 524 | 523 | "DeadFeeds": deadFeeds, |
| 525 | 524 | }) |
| 526 | 525 | } |
| 527 | - | |
| 528 | -func nullString(s string) sql.NullString { | |
| 529 | - return sql.NullString{String: s, Valid: s != ""} | |
| 530 | -} | |
| @@ -2,7 +2,6 @@ package server | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | - "database/sql" | ||
| 6 | "errors" | 5 | "errors" |
| 7 | "net/http" | 6 | "net/http" |
| 8 | "net/url" | 7 | "net/url" |
| @@ -200,11 +199,11 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 200 | 199 | ||
| 201 | f := &db.Feed{ | 200 | f := &db.Feed{ |
| 202 | FeedURL: feedURL, | 201 | FeedURL: feedURL, |
| 203 | - Title: nullString(feedTitle), | 202 | + Title: db.NullStr(feedTitle), |
| 204 | - SiteURL: nullString(result.Feed.SiteURL), | 203 | + SiteURL: db.NullStr(result.Feed.SiteURL), |
| 205 | - Description: nullString(result.Feed.Description), | 204 | + Description: db.NullStr(result.Feed.Description), |
| 206 | - FeedType: nullString(result.Feed.Type), | 205 | + FeedType: db.NullStr(result.Feed.Type), |
| 207 | - FaviconURL: nullString(faviconURL), | 206 | + FaviconURL: db.NullStr(faviconURL), |
| 208 | } | 207 | } |
| 209 | if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil { | 208 | if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil { |
| 210 | s.logger.Error("failed to upsert feed", "error", err) | 209 | s.logger.Error("failed to upsert feed", "error", err) |
| @@ -351,9 +350,9 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | |||
| 351 | for _, fu := range feedURLs { | 350 | for _, fu := range feedURLs { |
| 352 | f := &db.Feed{ | 351 | f := &db.Feed{ |
| 353 | FeedURL: fu.URL, | 352 | FeedURL: fu.URL, |
| 354 | - Title: nullString(fu.Title), | 353 | + Title: db.NullStr(fu.Title), |
| 355 | - SiteURL: nullString(fu.SiteURL), | 354 | + SiteURL: db.NullStr(fu.SiteURL), |
| 356 | - Description: nullString(fu.Description), | 355 | + Description: db.NullStr(fu.Description), |
| 357 | } | 356 | } |
| 358 | if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil { | 357 | if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil { |
| 359 | s.logger.Error("failed to upsert feed", "error", upsertErr) | 358 | s.logger.Error("failed to upsert feed", "error", upsertErr) |
| @@ -524,7 +523,3 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { | |||
| 524 | "DeadFeeds": deadFeeds, | 523 | "DeadFeeds": deadFeeds, |
| 525 | }) | 524 | }) |
| 526 | } | 525 | } |
| 527 | - | ||
| 528 | -func nullString(s string) sql.NullString { | ||
| 529 | - return sql.NullString{String: s, Valid: s != ""} | ||
| 530 | -} | ||
modified
internal/server/recs_handler.go +11 -43 | @@ -5,54 +5,22 @@ import ( | ||
| 5 | 5 | ) |
| 6 | 6 | |
| 7 | 7 | func (s *Server) handleDismissFeedRecommendation(w http.ResponseWriter, r *http.Request) { |
| 8 | - user := currentUser(r) | |
| 9 | - feedURL := r.FormValue("feed_url") | |
| 10 | - if feedURL == "" { | |
| 11 | - http.Error(w, "feed_url required", http.StatusBadRequest) | |
| 12 | - return | |
| 13 | - } | |
| 14 | - | |
| 15 | - reason := r.FormValue("reason") | |
| 16 | - if reason == "" { | |
| 17 | - reason = "not_interested" | |
| 18 | - } | |
| 19 | - | |
| 20 | - if err := s.engine.DismissFeed(r.Context(), user.DID, feedURL, reason); err != nil { | |
| 21 | - s.logger.Error("failed to dismiss feed recommendation", "error", err) | |
| 22 | - http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 23 | - return | |
| 24 | - } | |
| 25 | - | |
| 26 | - w.WriteHeader(http.StatusOK) | |
| 8 | + s.handleDismiss(w, r, "feed_url", "feed") | |
| 27 | 9 | } |
| 28 | 10 | |
| 29 | 11 | func (s *Server) handleDismissArticleRecommendation(w http.ResponseWriter, r *http.Request) { |
| 30 | - user := currentUser(r) | |
| 31 | - articleURL := r.FormValue("article_url") | |
| 32 | - if articleURL == "" { | |
| 33 | - http.Error(w, "article_url required", http.StatusBadRequest) | |
| 34 | - return | |
| 35 | - } | |
| 36 | - | |
| 37 | - reason := r.FormValue("reason") | |
| 38 | - if reason == "" { | |
| 39 | - reason = "not_interested" | |
| 40 | - } | |
| 41 | - | |
| 42 | - if err := s.engine.DismissArticle(r.Context(), user.DID, articleURL, reason); err != nil { | |
| 43 | - s.logger.Error("failed to dismiss article recommendation", "error", err) | |
| 44 | - http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 45 | - return | |
| 46 | - } | |
| 47 | - | |
| 48 | - w.WriteHeader(http.StatusOK) | |
| 12 | + s.handleDismiss(w, r, "article_url", "article") | |
| 49 | 13 | } |
| 50 | 14 | |
| 51 | 15 | func (s *Server) handleDismissPersonRecommendation(w http.ResponseWriter, r *http.Request) { |
| 16 | + s.handleDismiss(w, r, "target_did", "person") | |
| 17 | +} | |
| 18 | + | |
| 19 | +func (s *Server) handleDismiss(w http.ResponseWriter, r *http.Request, field, targetType string) { | |
| 52 | 20 | user := currentUser(r) |
| 53 | - targetDID := r.FormValue("target_did") | |
| 54 | - if targetDID == "" { | |
| 55 | - http.Error(w, "target_did required", http.StatusBadRequest) | |
| 21 | + targetID := r.FormValue(field) | |
| 22 | + if targetID == "" { | |
| 23 | + http.Error(w, field+" required", http.StatusBadRequest) | |
| 56 | 24 | return |
| 57 | 25 | } |
| 58 | 26 | |
| @@ -61,8 +29,8 @@ func (s *Server) handleDismissPersonRecommendation(w http.ResponseWriter, r *htt | ||
| 61 | 29 | reason = "not_interested" |
| 62 | 30 | } |
| 63 | 31 | |
| 64 | - if err := s.engine.DismissPerson(r.Context(), user.DID, targetDID, reason); err != nil { | |
| 65 | - s.logger.Error("failed to dismiss person recommendation", "error", err) | |
| 32 | + if err := s.engine.Dismiss(r.Context(), user.DID, targetType, targetID, reason); err != nil { | |
| 33 | + s.logger.Error("failed to dismiss recommendation", "error", err, "type", targetType) | |
| 66 | 34 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 67 | 35 | return |
| 68 | 36 | } |
| @@ -5,54 +5,22 @@ import ( | |||
| 5 | ) | 5 | ) |
| 6 | 6 | ||
| 7 | func (s *Server) handleDismissFeedRecommendation(w http.ResponseWriter, r *http.Request) { | 7 | func (s *Server) handleDismissFeedRecommendation(w http.ResponseWriter, r *http.Request) { |
| 8 | - user := currentUser(r) | 8 | + s.handleDismiss(w, r, "feed_url", "feed") |
| 9 | - feedURL := r.FormValue("feed_url") | ||
| 10 | - if feedURL == "" { | ||
| 11 | - http.Error(w, "feed_url required", http.StatusBadRequest) | ||
| 12 | - return | ||
| 13 | - } | ||
| 14 | - | ||
| 15 | - reason := r.FormValue("reason") | ||
| 16 | - if reason == "" { | ||
| 17 | - reason = "not_interested" | ||
| 18 | - } | ||
| 19 | - | ||
| 20 | - if err := s.engine.DismissFeed(r.Context(), user.DID, feedURL, reason); err != nil { | ||
| 21 | - s.logger.Error("failed to dismiss feed recommendation", "error", err) | ||
| 22 | - http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 23 | - return | ||
| 24 | - } | ||
| 25 | - | ||
| 26 | - w.WriteHeader(http.StatusOK) | ||
| 27 | } | 9 | } |
| 28 | 10 | ||
| 29 | func (s *Server) handleDismissArticleRecommendation(w http.ResponseWriter, r *http.Request) { | 11 | func (s *Server) handleDismissArticleRecommendation(w http.ResponseWriter, r *http.Request) { |
| 30 | - user := currentUser(r) | 12 | + s.handleDismiss(w, r, "article_url", "article") |
| 31 | - articleURL := r.FormValue("article_url") | ||
| 32 | - if articleURL == "" { | ||
| 33 | - http.Error(w, "article_url required", http.StatusBadRequest) | ||
| 34 | - return | ||
| 35 | - } | ||
| 36 | - | ||
| 37 | - reason := r.FormValue("reason") | ||
| 38 | - if reason == "" { | ||
| 39 | - reason = "not_interested" | ||
| 40 | - } | ||
| 41 | - | ||
| 42 | - if err := s.engine.DismissArticle(r.Context(), user.DID, articleURL, reason); err != nil { | ||
| 43 | - s.logger.Error("failed to dismiss article recommendation", "error", err) | ||
| 44 | - http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 45 | - return | ||
| 46 | - } | ||
| 47 | - | ||
| 48 | - w.WriteHeader(http.StatusOK) | ||
| 49 | } | 13 | } |
| 50 | 14 | ||
| 51 | func (s *Server) handleDismissPersonRecommendation(w http.ResponseWriter, r *http.Request) { | 15 | func (s *Server) handleDismissPersonRecommendation(w http.ResponseWriter, r *http.Request) { |
| 16 | + s.handleDismiss(w, r, "target_did", "person") | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +func (s *Server) handleDismiss(w http.ResponseWriter, r *http.Request, field, targetType string) { | ||
| 52 | user := currentUser(r) | 20 | user := currentUser(r) |
| 53 | - targetDID := r.FormValue("target_did") | 21 | + targetID := r.FormValue(field) |
| 54 | - if targetDID == "" { | 22 | + if targetID == "" { |
| 55 | - http.Error(w, "target_did required", http.StatusBadRequest) | 23 | + http.Error(w, field+" required", http.StatusBadRequest) |
| 56 | return | 24 | return |
| 57 | } | 25 | } |
| 58 | 26 | ||
| @@ -61,8 +29,8 @@ func (s *Server) handleDismissPersonRecommendation(w http.ResponseWriter, r *htt | |||
| 61 | reason = "not_interested" | 29 | reason = "not_interested" |
| 62 | } | 30 | } |
| 63 | 31 | ||
| 64 | - if err := s.engine.DismissPerson(r.Context(), user.DID, targetDID, reason); err != nil { | 32 | + if err := s.engine.Dismiss(r.Context(), user.DID, targetType, targetID, reason); err != nil { |
| 65 | - s.logger.Error("failed to dismiss person recommendation", "error", err) | 33 | + s.logger.Error("failed to dismiss recommendation", "error", err, "type", targetType) |
| 66 | http.Error(w, err.Error(), http.StatusInternalServerError) | 34 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 67 | return | 35 | return |
| 68 | } | 36 | } |