nandi/gleanpublic Fork 0
71c0a01
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add recommendation language filtersUnverified

Julien Robert committed 2026-05-07T20:37:00+02:00 Browse files
71c0a01 parent: 2976683
modified .env.example +6 -0
@@ -18,3 +18,9 @@ GLEAN_EMBED_BASE_URL=https://llms.example.com/v1
1818 GLEAN_EMBED_API_KEY=API-KEY-001
1919 GLEAN_EMBED_MODEL=qwen-embedding-4b
2020 GLEAN_EMBED_DIMENSION=2560
21+# LLM for article language detection and other text tasks
22+# Any OpenAI-compatible /v1/chat/completions endpoint works.
23+# Without an LLM, all articles default to English and language filtering is disabled.
24+GLEAN_LLM_BASE_URL=https://llms.example.com/v1
25+GLEAN_LLM_API_KEY=API-KEY-001
26+GLEAN_LLM_MODEL=qwen3.5-35b-a3b
@@ -18,3 +18,9 @@ GLEAN_EMBED_BASE_URL=https://llms.example.com/v1
18 GLEAN_EMBED_API_KEY=API-KEY-00118 GLEAN_EMBED_API_KEY=API-KEY-001
19 GLEAN_EMBED_MODEL=qwen-embedding-4b19 GLEAN_EMBED_MODEL=qwen-embedding-4b
20 GLEAN_EMBED_DIMENSION=256020 GLEAN_EMBED_DIMENSION=2560
21+# LLM for article language detection and other text tasks
22+# Any OpenAI-compatible /v1/chat/completions endpoint works.
23+# Without an LLM, all articles default to English and language filtering is disabled.
24+GLEAN_LLM_BASE_URL=https://llms.example.com/v1
25+GLEAN_LLM_API_KEY=API-KEY-001
26+GLEAN_LLM_MODEL=qwen3.5-35b-a3b
modified docs/specs.md +27 -6
@@ -352,6 +352,7 @@ CREATE TABLE articles (
352352 published DATETIME,
353353 updated DATETIME,
354354 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
355+ language TEXT NOT NULL DEFAULT '',
355356 UNIQUE(feed_url, guid)
356357 );
357358
@@ -361,6 +362,8 @@ CREATE INDEX idx_articles_published ON articles(published DESC);
361362
362363 Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. `full_content` stores scraped article content fetched from the original URL. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.).
363364
365+The `language` column stores the ISO 639-1 code detected by the LLM (e.g. `en`, `fr`, `ja`). It defaults to empty (`''`) and is populated by the cron job when `GLEAN_LLM_BASE_URL` is configured.
366+
364367 ### 4.5 Read State
365368
366369 Read/unread state is tracked per user per article:
@@ -463,7 +466,8 @@ CREATE TABLE users (
463466 did TEXT PRIMARY KEY,
464467 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
465468 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
466- follows_dirty BOOLEAN NOT NULL DEFAULT 1
469+ follows_dirty BOOLEAN NOT NULL DEFAULT 1,
470+ languages TEXT
467471 );
468472 ```
469473
@@ -526,11 +530,13 @@ CREATE TABLE articles (
526530 published DATETIME,
527531 updated DATETIME,
528532 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
533+ language TEXT NOT NULL DEFAULT '',
529534 UNIQUE(feed_url, guid)
530535 );
531536
532537 CREATE INDEX idx_articles_feed ON articles(feed_url);
533538 CREATE INDEX idx_articles_published ON articles(published DESC);
539+CREATE INDEX idx_articles_language ON articles(language);
534540 ```
535541
536542 ### 6.5 Read State (`<base>_articles`)
@@ -708,6 +714,8 @@ score = like_signal * w_like
708714
709715 Content signal uses embedding vectors: the user's liked article embeddings are averaged into a single interest vector, then a KNN query against the `article_embeddings` vec0 table finds semantically similar articles. This requires an embedder to be configured; without it, the content signal is 0.
710716
717+**Language filtering**: Users can set preferred languages on their profile (stored in the `user_settings` table as a JSON array of ISO 639-1 codes). When set, article recommendations are filtered to only include articles whose `language` column matches one of the selected codes **or** whose `language` is empty (not yet classified). This ensures users with language preferences still see all recommendations when the LLM hasn't run or hasn't classified certain articles yet. If no languages are set (empty/nil), all articles are shown regardless of language.
718+
711719 ### 7.5 User Feedback (Dismiss)
712720
713721 Users can dismiss recommendations they don't want to see again:
@@ -763,9 +771,10 @@ A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`
763771 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)
764772 3. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)
765773 4. **Compute article embeddings**: Embed new articles (`title + summary + content`, excluding `full_content` to stay within embedding model token limits) via embedding API into `article_embeddings` vec0 table (skipped if no embedder configured)
766-5. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
767-6. **Compute signal profiles**: Per-user category/tag/like summaries
768-7. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
774+5. **Detect article languages**: Batch-classify article languages via LLM, updating the `language` column (skipped if no LLM configured)
775+6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
776+7. **Compute signal profiles**: Per-user category/tag/like summaries
777+8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
769778
770779 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
771780
@@ -856,6 +865,14 @@ When `GLEAN_EMBED_BASE_URL` is configured, article text and feed descriptions ar
856865
857866 The embedder uses the official `github.com/openai/openai-go` SDK with `option.WithBaseURL()`, so any OpenAI-compatible `/v1/embeddings` endpoint works (OpenAI, Gemini, Ollama, local inference servers).
858867
868+### 7.14 LLM Client (optional)
869+
870+When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint.
871+
872+**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to English (`en`).
873+
874+Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable.
875+
859876 vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):
860877
861878 ```sql
@@ -916,6 +933,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
916933 | `/library/{id}/delete` | POST | Delete an annotation |
917934 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |
918935 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
936+| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) |
919937 | `/auth/login` | GET | Login page |
920938 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |
921939 | `/auth/resolve` | GET | Resolve handle to DID |
@@ -979,7 +997,8 @@ glean/
979997 cluster/
980998 jaccard.go # Jaccard similarity computation
981999 embed.go # Embedder interface + OpenAI-compatible implementation
982- article.go # Article + feed embedding computation, vec0 KNN content boost
1000+ llm.go # LLM client for language detection and text tasks
1001+ article.go # Article + feed embedding computation, vec0 KNN content boost, language detection
9831002 scoring.go # Feed + people + article recommendation queries (on-demand)
9841003 social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
9851004 dismiss.go # Dismiss + impression tracking
@@ -997,6 +1016,7 @@ glean/
9971016 stats_handler.go # Stats handler (Prometheus metrics display)
9981017 index_handler.go # Landing page handler
9991018 profile_handler.go # Public profile handler
1019+ settings_handler.go # User settings (language preferences)
10001020 terms_handler.go # Terms of service handler
10011021 pagination.go # Pagination helpers
10021022 middleware.go # Auth, logging, CSRF middleware
@@ -1077,13 +1097,14 @@ Cron (every 10m) ──► Cluster Engine
10771097 Compute feed similarity
10781098 Compute user similarity
10791099 Compute article embeddings (if embedder configured)
1100+ Detect article languages (if LLM configured)
10801101 Compute follow distances
10811102 Compute signal profiles
10821103 Auto-dismiss stale recommendations
10831104
10841105 Browser GET /dashboard Server
10851106
1086- Compute recommendations on-demand
1107+ Compute recommendations on-demand (filtered by user's language preferences)
10871108 ├─► Fetch feed metadata
10881109 └─◄ Render recommendation cards (htmx)
10891110 ```
@@ -352,6 +352,7 @@ CREATE TABLE articles (
352 published DATETIME,352 published DATETIME,
353 updated DATETIME,353 updated DATETIME,
354 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,354 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
355+ language TEXT NOT NULL DEFAULT '',
355 UNIQUE(feed_url, guid)356 UNIQUE(feed_url, guid)
356 );357 );
357 358
@@ -361,6 +362,8 @@ CREATE INDEX idx_articles_published ON articles(published DESC);
361 362
362 Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. `full_content` stores scraped article content fetched from the original URL. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.).363 Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. `full_content` stores scraped article content fetched from the original URL. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.).
363 364
365+The `language` column stores the ISO 639-1 code detected by the LLM (e.g. `en`, `fr`, `ja`). It defaults to empty (`''`) and is populated by the cron job when `GLEAN_LLM_BASE_URL` is configured.
366+
364 ### 4.5 Read State367 ### 4.5 Read State
365 368
366 Read/unread state is tracked per user per article:369 Read/unread state is tracked per user per article:
@@ -463,7 +466,8 @@ CREATE TABLE users (
463 did TEXT PRIMARY KEY,466 did TEXT PRIMARY KEY,
464 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,467 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
465 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,468 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
466- follows_dirty BOOLEAN NOT NULL DEFAULT 1469+ follows_dirty BOOLEAN NOT NULL DEFAULT 1,
470+ languages TEXT
467 );471 );
468 ```472 ```
469 473
@@ -526,11 +530,13 @@ CREATE TABLE articles (
526 published DATETIME,530 published DATETIME,
527 updated DATETIME,531 updated DATETIME,
528 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,532 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
533+ language TEXT NOT NULL DEFAULT '',
529 UNIQUE(feed_url, guid)534 UNIQUE(feed_url, guid)
530 );535 );
531 536
532 CREATE INDEX idx_articles_feed ON articles(feed_url);537 CREATE INDEX idx_articles_feed ON articles(feed_url);
533 CREATE INDEX idx_articles_published ON articles(published DESC);538 CREATE INDEX idx_articles_published ON articles(published DESC);
539+CREATE INDEX idx_articles_language ON articles(language);
534 ```540 ```
535 541
536 ### 6.5 Read State (`<base>_articles`)542 ### 6.5 Read State (`<base>_articles`)
@@ -708,6 +714,8 @@ score = like_signal * w_like
708 714
709 Content signal uses embedding vectors: the user's liked article embeddings are averaged into a single interest vector, then a KNN query against the `article_embeddings` vec0 table finds semantically similar articles. This requires an embedder to be configured; without it, the content signal is 0.715 Content signal uses embedding vectors: the user's liked article embeddings are averaged into a single interest vector, then a KNN query against the `article_embeddings` vec0 table finds semantically similar articles. This requires an embedder to be configured; without it, the content signal is 0.
710 716
717+**Language filtering**: Users can set preferred languages on their profile (stored in the `user_settings` table as a JSON array of ISO 639-1 codes). When set, article recommendations are filtered to only include articles whose `language` column matches one of the selected codes **or** whose `language` is empty (not yet classified). This ensures users with language preferences still see all recommendations when the LLM hasn't run or hasn't classified certain articles yet. If no languages are set (empty/nil), all articles are shown regardless of language.
718+
711 ### 7.5 User Feedback (Dismiss)719 ### 7.5 User Feedback (Dismiss)
712 720
713 Users can dismiss recommendations they don't want to see again:721 Users can dismiss recommendations they don't want to see again:
@@ -763,9 +771,10 @@ A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`
763 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)771 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity)
764 3. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)772 3. **Compute user similarity**: Batch-update `user_similarity` table (subscription Jaccard + time-decayed likes + tags + follow boost)
765 4. **Compute article embeddings**: Embed new articles (`title + summary + content`, excluding `full_content` to stay within embedding model token limits) via embedding API into `article_embeddings` vec0 table (skipped if no embedder configured)773 4. **Compute article embeddings**: Embed new articles (`title + summary + content`, excluding `full_content` to stay within embedding model token limits) via embedding API into `article_embeddings` vec0 table (skipped if no embedder configured)
766-5. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)774+5. **Detect article languages**: Batch-classify article languages via LLM, updating the `language` column (skipped if no LLM configured)
767-6. **Compute signal profiles**: Per-user category/tag/like summaries775+6. **Compute follow distances**: Incremental BFS for dirty users (1-hop through 3-hop from `follows` table)
768-7. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action776+7. **Compute signal profiles**: Per-user category/tag/like summaries
777+8. **Auto-dismiss stale**: Dismiss items shown >=5 times over >5 days without action
769 778
770 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.779 Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
771 780
@@ -856,6 +865,14 @@ When `GLEAN_EMBED_BASE_URL` is configured, article text and feed descriptions ar
856 865
857 The embedder uses the official `github.com/openai/openai-go` SDK with `option.WithBaseURL()`, so any OpenAI-compatible `/v1/embeddings` endpoint works (OpenAI, Gemini, Ollama, local inference servers).866 The embedder uses the official `github.com/openai/openai-go` SDK with `option.WithBaseURL()`, so any OpenAI-compatible `/v1/embeddings` endpoint works (OpenAI, Gemini, Ollama, local inference servers).
858 867
868+### 7.14 LLM Client (optional)
869+
870+When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint.
871+
872+**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to English (`en`).
873+
874+Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable.
875+
859 vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):876 vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536):
860 877
861 ```sql878 ```sql
@@ -916,6 +933,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
916 | `/library/{id}/delete` | POST | Delete an annotation |933 | `/library/{id}/delete` | POST | Delete an annotation |
917 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |934 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |
918 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |935 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
936+| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) |
919 | `/auth/login` | GET | Login page |937 | `/auth/login` | GET | Login page |
920 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |938 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |
921 | `/auth/resolve` | GET | Resolve handle to DID |939 | `/auth/resolve` | GET | Resolve handle to DID |
@@ -979,7 +997,8 @@ glean/
979 cluster/997 cluster/
980 jaccard.go # Jaccard similarity computation998 jaccard.go # Jaccard similarity computation
981 embed.go # Embedder interface + OpenAI-compatible implementation999 embed.go # Embedder interface + OpenAI-compatible implementation
982- article.go # Article + feed embedding computation, vec0 KNN content boost1000+ llm.go # LLM client for language detection and text tasks
1001+ article.go # Article + feed embedding computation, vec0 KNN content boost, language detection
983 scoring.go # Feed + people + article recommendation queries (on-demand)1002 scoring.go # Feed + people + article recommendation queries (on-demand)
984 social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)1003 social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
985 dismiss.go # Dismiss + impression tracking1004 dismiss.go # Dismiss + impression tracking
@@ -997,6 +1016,7 @@ glean/
997 stats_handler.go # Stats handler (Prometheus metrics display)1016 stats_handler.go # Stats handler (Prometheus metrics display)
998 index_handler.go # Landing page handler1017 index_handler.go # Landing page handler
999 profile_handler.go # Public profile handler1018 profile_handler.go # Public profile handler
1019+ settings_handler.go # User settings (language preferences)
1000 terms_handler.go # Terms of service handler1020 terms_handler.go # Terms of service handler
1001 pagination.go # Pagination helpers1021 pagination.go # Pagination helpers
1002 middleware.go # Auth, logging, CSRF middleware1022 middleware.go # Auth, logging, CSRF middleware
@@ -1077,13 +1097,14 @@ Cron (every 10m) ──► Cluster Engine
1077 Compute feed similarity1097 Compute feed similarity
1078 Compute user similarity1098 Compute user similarity
1079 Compute article embeddings (if embedder configured)1099 Compute article embeddings (if embedder configured)
1100+ Detect article languages (if LLM configured)
1080 Compute follow distances1101 Compute follow distances
1081 Compute signal profiles1102 Compute signal profiles
1082 Auto-dismiss stale recommendations1103 Auto-dismiss stale recommendations
1083 1104
1084 Browser GET /dashboard Server1105 Browser GET /dashboard Server
1085 1106
1086- Compute recommendations on-demand1107+ Compute recommendations on-demand (filtered by user's language preferences)
1087 ├─► Fetch feed metadata1108 ├─► Fetch feed metadata
1088 └─◄ Render recommendation cards (htmx)1109 └─◄ Render recommendation cards (htmx)
1089 ```1110 ```
modified internal/cluster/article.go +106 -0
@@ -364,6 +364,112 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
364364 return nil
365365 }
366366
367+// DetectArticleLanguages detects the language of articles that still have the
368+// default language ('en') using the embedding model. It processes articles in
369+// batches alongside the embedding computation to reuse the same API client.
370+func (e *Engine) DetectArticleLanguages(ctx context.Context) error {
371+ if e.llm == nil {
372+ e.logger.Debug("language detection skipped, no LLM client")
373+ return nil
374+ }
375+
376+ conn, err := e.db.Conn(ctx)
377+ if err != nil {
378+ return err
379+ }
380+ defer conn.Close()
381+
382+ rows, err := conn.QueryContext(ctx, `
383+ SELECT id, COALESCE(title, '') || ' ' || COALESCE(summary, '')
384+ FROM articles.articles
385+ WHERE language = ''
386+ ORDER BY id DESC
387+ LIMIT 5000
388+ `)
389+ if err != nil {
390+ return err
391+ }
392+
393+ type article struct {
394+ id int64
395+ text string
396+ }
397+ var batch []article
398+ for rows.Next() {
399+ var a article
400+ if err := rows.Scan(&a.id, &a.text); err != nil {
401+ rows.Close()
402+ return err
403+ }
404+ if strings.TrimSpace(a.text) == "" {
405+ continue
406+ }
407+ if len(a.text) > 500 {
408+ a.text = a.text[:500]
409+ }
410+ batch = append(batch, a)
411+ }
412+ rows.Close()
413+
414+ if len(batch) == 0 {
415+ e.logger.Info("article languages up to date")
416+ return nil
417+ }
418+
419+ for i := 0; i < len(batch); i += embedBatchSize {
420+ end := min(i+embedBatchSize, len(batch))
421+ sub := batch[i:end]
422+
423+ texts := make([]string, len(sub))
424+ for j, a := range sub {
425+ texts[j] = a.text
426+ }
427+
428+ langs, err := e.llm.DetectLanguages(ctx, texts)
429+ if err != nil {
430+ return fmt.Errorf("detect languages batch %d: %w", i/embedBatchSize, err)
431+ }
432+
433+ tx, err := conn.BeginTx(ctx, nil)
434+ if err != nil {
435+ return err
436+ }
437+ defer func() { _ = tx.Rollback() }()
438+
439+ stmt, err := tx.PrepareContext(ctx, `UPDATE articles.articles SET language = ? WHERE id = ? AND language = ''`)
440+ if err != nil {
441+ return err
442+ }
443+ defer stmt.Close()
444+
445+ updated := 0
446+ for j, lang := range langs {
447+ if lang == "" {
448+ lang = "en"
449+ }
450+ res, err := stmt.ExecContext(ctx, lang, sub[j].id)
451+ if err != nil {
452+ return fmt.Errorf("update language: %w", err)
453+ }
454+ n, _ := res.RowsAffected()
455+ updated += int(n)
456+ }
457+
458+ if err := tx.Commit(); err != nil {
459+ return err
460+ }
461+
462+ e.logger.Info("article languages detected",
463+ slog.Int("batch", i/embedBatchSize),
464+ slog.Int("count", len(sub)),
465+ slog.Int("updated", updated),
466+ )
467+ }
468+
469+ e.logger.Info("article languages computed", slog.Int("total", len(batch)))
470+ return nil
471+}
472+
367473 func (e *Engine) ensureContentBoostTable(ctx context.Context, conn *sql.Conn) error {
368474 _, err := conn.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _content_boost (article_id INT PRIMARY KEY, score REAL)`)
369475 if err != nil {
@@ -364,6 +364,112 @@ func (e *Engine) ComputeFeedEmbeddings(ctx context.Context) error {
364 return nil364 return nil
365 }365 }
366 366
367+// DetectArticleLanguages detects the language of articles that still have the
368+// default language ('en') using the embedding model. It processes articles in
369+// batches alongside the embedding computation to reuse the same API client.
370+func (e *Engine) DetectArticleLanguages(ctx context.Context) error {
371+ if e.llm == nil {
372+ e.logger.Debug("language detection skipped, no LLM client")
373+ return nil
374+ }
375+
376+ conn, err := e.db.Conn(ctx)
377+ if err != nil {
378+ return err
379+ }
380+ defer conn.Close()
381+
382+ rows, err := conn.QueryContext(ctx, `
383+ SELECT id, COALESCE(title, '') || ' ' || COALESCE(summary, '')
384+ FROM articles.articles
385+ WHERE language = ''
386+ ORDER BY id DESC
387+ LIMIT 5000
388+ `)
389+ if err != nil {
390+ return err
391+ }
392+
393+ type article struct {
394+ id int64
395+ text string
396+ }
397+ var batch []article
398+ for rows.Next() {
399+ var a article
400+ if err := rows.Scan(&a.id, &a.text); err != nil {
401+ rows.Close()
402+ return err
403+ }
404+ if strings.TrimSpace(a.text) == "" {
405+ continue
406+ }
407+ if len(a.text) > 500 {
408+ a.text = a.text[:500]
409+ }
410+ batch = append(batch, a)
411+ }
412+ rows.Close()
413+
414+ if len(batch) == 0 {
415+ e.logger.Info("article languages up to date")
416+ return nil
417+ }
418+
419+ for i := 0; i < len(batch); i += embedBatchSize {
420+ end := min(i+embedBatchSize, len(batch))
421+ sub := batch[i:end]
422+
423+ texts := make([]string, len(sub))
424+ for j, a := range sub {
425+ texts[j] = a.text
426+ }
427+
428+ langs, err := e.llm.DetectLanguages(ctx, texts)
429+ if err != nil {
430+ return fmt.Errorf("detect languages batch %d: %w", i/embedBatchSize, err)
431+ }
432+
433+ tx, err := conn.BeginTx(ctx, nil)
434+ if err != nil {
435+ return err
436+ }
437+ defer func() { _ = tx.Rollback() }()
438+
439+ stmt, err := tx.PrepareContext(ctx, `UPDATE articles.articles SET language = ? WHERE id = ? AND language = ''`)
440+ if err != nil {
441+ return err
442+ }
443+ defer stmt.Close()
444+
445+ updated := 0
446+ for j, lang := range langs {
447+ if lang == "" {
448+ lang = "en"
449+ }
450+ res, err := stmt.ExecContext(ctx, lang, sub[j].id)
451+ if err != nil {
452+ return fmt.Errorf("update language: %w", err)
453+ }
454+ n, _ := res.RowsAffected()
455+ updated += int(n)
456+ }
457+
458+ if err := tx.Commit(); err != nil {
459+ return err
460+ }
461+
462+ e.logger.Info("article languages detected",
463+ slog.Int("batch", i/embedBatchSize),
464+ slog.Int("count", len(sub)),
465+ slog.Int("updated", updated),
466+ )
467+ }
468+
469+ e.logger.Info("article languages computed", slog.Int("total", len(batch)))
470+ return nil
471+}
472+
367 func (e *Engine) ensureContentBoostTable(ctx context.Context, conn *sql.Conn) error {473 func (e *Engine) ensureContentBoostTable(ctx context.Context, conn *sql.Conn) error {
368 _, err := conn.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _content_boost (article_id INT PRIMARY KEY, score REAL)`)474 _, err := conn.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS _content_boost (article_id INT PRIMARY KEY, score REAL)`)
369 if err != nil {475 if err != nil {
modified internal/cluster/cron.go +3 -0
@@ -44,6 +44,9 @@ func (c *Cron) Run(ctx context.Context) error {
4444 if err := c.engine.ComputeArticleEmbeddings(ctx); err != nil {
4545 c.engine.logger.Error("article embeddings failed", "error", err)
4646 }
47+ if err := c.engine.DetectArticleLanguages(ctx); err != nil {
48+ c.engine.logger.Error("language detection failed", "error", err)
49+ }
4750 if err := c.engine.ComputeSignalProfiles(ctx); err != nil {
4851 c.engine.logger.Error("signal profiles failed", "error", err)
4952 }
@@ -44,6 +44,9 @@ func (c *Cron) Run(ctx context.Context) error {
44 if err := c.engine.ComputeArticleEmbeddings(ctx); err != nil {44 if err := c.engine.ComputeArticleEmbeddings(ctx); err != nil {
45 c.engine.logger.Error("article embeddings failed", "error", err)45 c.engine.logger.Error("article embeddings failed", "error", err)
46 }46 }
47+ if err := c.engine.DetectArticleLanguages(ctx); err != nil {
48+ c.engine.logger.Error("language detection failed", "error", err)
49+ }
47 if err := c.engine.ComputeSignalProfiles(ctx); err != nil {50 if err := c.engine.ComputeSignalProfiles(ctx); err != nil {
48 c.engine.logger.Error("signal profiles failed", "error", err)51 c.engine.logger.Error("signal profiles failed", "error", err)
49 }52 }
modified internal/cluster/jaccard.go +9 -4
@@ -37,12 +37,17 @@ type Engine struct {
3737 mu sync.Mutex
3838 config Config
3939 embedder Embedder
40+ llm *LLMClient
4041 }
4142
42-// NewEngine creates a new recommendation engine. Pass nil for embedder to
43-// disable content-based signals (no embedding computation, no KNN queries).
44-func NewEngine(db *sql.DB, embedder Embedder, logger *slog.Logger) *Engine {
45- return &Engine{db: db, logger: logger, config: DefaultConfig(), embedder: embedder}
43+func NewEngine(db *sql.DB, embedder Embedder, llm *LLMClient, logger *slog.Logger) *Engine {
44+ return &Engine{
45+ db: db,
46+ logger: logger,
47+ config: DefaultConfig(),
48+ embedder: embedder,
49+ llm: llm,
50+ }
4651 }
4752
4853 // ComputeFeedSimilarity recomputes the feed_similarity table: time-decayed
@@ -37,12 +37,17 @@ type Engine struct {
37 mu sync.Mutex37 mu sync.Mutex
38 config Config38 config Config
39 embedder Embedder39 embedder Embedder
40+ llm *LLMClient
40 }41 }
41 42
42-// NewEngine creates a new recommendation engine. Pass nil for embedder to43+func NewEngine(db *sql.DB, embedder Embedder, llm *LLMClient, logger *slog.Logger) *Engine {
43-// disable content-based signals (no embedding computation, no KNN queries).44+ return &Engine{
44-func NewEngine(db *sql.DB, embedder Embedder, logger *slog.Logger) *Engine {45+ db: db,
45- return &Engine{db: db, logger: logger, config: DefaultConfig(), embedder: embedder}46+ logger: logger,
47+ config: DefaultConfig(),
48+ embedder: embedder,
49+ llm: llm,
50+ }
46 }51 }
47 52
48 // ComputeFeedSimilarity recomputes the feed_similarity table: time-decayed53 // ComputeFeedSimilarity recomputes the feed_similarity table: time-decayed
modified internal/cluster/jaccard_test.go +131 -38
@@ -94,7 +94,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
9494 }
9595
9696 func newTestEngine(dbs *db.Store) *Engine {
97- return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), slog.Default())
97+ return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), nil, slog.Default())
9898 }
9999
100100 func TestComputeFeedSimilarity(t *testing.T) {
@@ -638,60 +638,153 @@ func TestComputeArticleEmbeddings(t *testing.T) {
638638 assert.Equal(t, count, 3, "expected 3 article embeddings")
639639 }
640640
641-func TestArticleRecommendationsWithContentBoost(t *testing.T) {
641+func seedArticleRecData(t *testing.T, ctx context.Context, dbs *db.Store) {
642+ t.Helper()
643+
644+ for _, did := range []string{"did:test:alice", "did:test:bob"} {
645+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, did)
646+ assert.NilError(t, err)
647+ }
648+
649+ for _, f := range []struct{ url, title string }{
650+ {"https://tech.com/feed", "Tech Feed"},
651+ {"https://dev.com/feed", "Dev Feed"},
652+ {"https://shared.com/feed", "Shared Feed"},
653+ } {
654+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`, f.url, f.title, f.url)
655+ assert.NilError(t, err)
656+ }
657+
658+ subs := []struct{ user, feed string }{
659+ {"did:test:alice", "https://tech.com/feed"},
660+ {"did:test:alice", "https://shared.com/feed"},
661+ {"did:test:bob", "https://dev.com/feed"},
662+ {"did:test:bob", "https://shared.com/feed"},
663+ }
664+ for _, s := range subs {
665+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, s.user, s.feed)
666+ assert.NilError(t, err)
667+ }
668+
669+ articles := []struct{ feed, guid, title, summary, url, lang string }{
670+ {"https://tech.com/feed", "1", "golang programming tutorial", "learn go programming", "https://tech.com/go", "en"},
671+ {"https://dev.com/feed", "2", "rust programming tutorial", "learn rust programming", "https://dev.com/rust", "fr"},
672+ {"https://dev.com/feed", "3", "cooking dinner recipes", "easy dinner recipes", "https://dev.com/cook", ""},
673+ {"https://dev.com/feed", "4", "python data science", "python for data analysis", "https://dev.com/python", "de"},
674+ }
675+ for _, a := range articles {
676+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published, language) VALUES (?, ?, ?, ?, ?, datetime('now'), ?)`,
677+ a.feed, a.guid, a.title, a.summary, a.url, a.lang)
678+ assert.NilError(t, err)
679+ }
680+
681+ likes := []struct{ uri, author, feed, article string }{
682+ {"at://alice/like/1", "did:test:alice", "https://tech.com/feed", "https://tech.com/go"},
683+ {"at://bob/like/2", "did:test:bob", "https://dev.com/feed", "https://dev.com/rust"},
684+ {"at://bob/like/3", "did:test:bob", "https://dev.com/feed", "https://dev.com/cook"},
685+ {"at://bob/like/4", "did:test:bob", "https://dev.com/feed", "https://dev.com/python"},
686+ }
687+ for _, l := range likes {
688+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
689+ l.uri, l.author, l.feed, l.article)
690+ assert.NilError(t, err)
691+ }
692+}
693+
694+func TestArticleRecommendations_LanguageFilter_ShowsUnclassified(t *testing.T) {
642695 ctx := context.Background()
643696 dbs := setupClusterTestDB(t)
697+ seedArticleRecData(t, ctx, dbs)
644698
645- _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice")
646- assert.NilError(t, err)
647- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")
648- assert.NilError(t, err)
699+ engine := newTestEngine(dbs)
700+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
701+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
702+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
649703
650- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
651- "https://tech.com/feed", "Tech Feed", "https://tech.com")
652- assert.NilError(t, err)
653- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
654- "https://dev.com/feed", "Dev Feed", "https://dev.com")
655- assert.NilError(t, err)
656- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
657- "https://shared.com/feed", "Shared Feed", "https://shared.com")
704+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", []string{"en"}, 10)
658705 assert.NilError(t, err)
706+ assert.Assert(t, len(recs) > 0, "alice should get recommendations with language filter")
659707
660- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://tech.com/feed")
661- assert.NilError(t, err)
662- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://shared.com/feed")
663- assert.NilError(t, err)
664- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://dev.com/feed")
665- assert.NilError(t, err)
666- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://shared.com/feed")
667- assert.NilError(t, err)
708+ urls := make(map[string]bool)
709+ for _, r := range recs {
710+ urls[r.URL] = true
711+ }
712+ assert.Assert(t, urls["https://tech.com/go"], "should include article with language 'en'")
713+ assert.Assert(t, urls["https://dev.com/cook"], "should include article with empty language (unclassified)")
714+ assert.Assert(t, !urls["https://dev.com/rust"], "should exclude article with language 'fr'")
715+ assert.Assert(t, !urls["https://dev.com/python"], "should exclude article with language 'de'")
716+}
668717
669- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
670- "https://tech.com/feed", "1", "golang programming tutorial", "learn go programming", "https://tech.com/go")
671- assert.NilError(t, err)
672- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
673- "https://dev.com/feed", "2", "rust programming tutorial", "learn rust programming", "https://dev.com/rust")
674- assert.NilError(t, err)
675- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
676- "https://dev.com/feed", "3", "cooking dinner recipes", "easy dinner recipes", "https://dev.com/cook")
677- assert.NilError(t, err)
718+func TestArticleRecommendations_LanguageFilter_MultipleLanguages(t *testing.T) {
719+ ctx := context.Background()
720+ dbs := setupClusterTestDB(t)
721+ seedArticleRecData(t, ctx, dbs)
678722
679- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
680- "at://alice/like/1", "did:test:alice", "https://tech.com/feed", "https://tech.com/go")
681- assert.NilError(t, err)
682- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
683- "at://bob/like/1", "did:test:bob", "https://dev.com/feed", "https://dev.com/rust")
723+ engine := newTestEngine(dbs)
724+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
725+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
726+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
727+
728+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", []string{"en", "fr"}, 10)
684729 assert.NilError(t, err)
730+ assert.Assert(t, len(recs) > 0, "alice should get recommendations with multi-language filter")
731+
732+ urls := make(map[string]bool)
733+ for _, r := range recs {
734+ urls[r.URL] = true
735+ }
736+ assert.Assert(t, urls["https://tech.com/go"], "should include article with language 'en'")
737+ assert.Assert(t, urls["https://dev.com/rust"], "should include article with language 'fr'")
738+ assert.Assert(t, urls["https://dev.com/cook"], "should include article with empty language (unclassified)")
739+ assert.Assert(t, !urls["https://dev.com/python"], "should exclude article with language 'de'")
740+}
741+
742+func TestArticleRecommendations_LanguageFilter_NoPreferences(t *testing.T) {
743+ ctx := context.Background()
744+ dbs := setupClusterTestDB(t)
745+ seedArticleRecData(t, ctx, dbs)
685746
686747 engine := newTestEngine(dbs)
748+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
749+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
750+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
751+
752+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", nil, 10)
753+ assert.NilError(t, err)
754+ assert.Assert(t, len(recs) > 0, "alice should get all recommendations without language filter")
755+
756+ urls := make(map[string]bool)
757+ for _, r := range recs {
758+ urls[r.URL] = true
759+ }
760+ assert.Assert(t, urls["https://tech.com/go"], "should include 'en' article")
761+ assert.Assert(t, urls["https://dev.com/rust"], "should include 'fr' article")
762+ assert.Assert(t, urls["https://dev.com/cook"], "should include unclassified article")
763+ assert.Assert(t, urls["https://dev.com/python"], "should include 'de' article")
764+}
765+
766+func TestArticleRecommendations_LanguageFilter_EmptyPreferences(t *testing.T) {
767+ ctx := context.Background()
768+ dbs := setupClusterTestDB(t)
769+ seedArticleRecData(t, ctx, dbs)
687770
771+ engine := newTestEngine(dbs)
688772 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
689773 assert.NilError(t, engine.ComputeUserSimilarity(ctx))
690774 assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
691775
692- recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", 10)
776+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", []string{}, 10)
693777 assert.NilError(t, err)
694- assert.Assert(t, len(recs) > 0, "alice should get article recommendations")
778+ assert.Assert(t, len(recs) > 0, "empty language list should show all articles")
779+
780+ urls := make(map[string]bool)
781+ for _, r := range recs {
782+ urls[r.URL] = true
783+ }
784+ assert.Assert(t, urls["https://tech.com/go"], "should include 'en' article")
785+ assert.Assert(t, urls["https://dev.com/rust"], "should include 'fr' article")
786+ assert.Assert(t, urls["https://dev.com/cook"], "should include unclassified article")
787+ assert.Assert(t, urls["https://dev.com/python"], "should include 'de' article")
695788 }
696789
697790 func TestFeedEmbeddingRecomputedOnDescriptionChange(t *testing.T) {
@@ -94,7 +94,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) {
94 }94 }
95 95
96 func newTestEngine(dbs *db.Store) *Engine {96 func newTestEngine(dbs *db.Store) *Engine {
97- return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), slog.Default())97+ return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), nil, slog.Default())
98 }98 }
99 99
100 func TestComputeFeedSimilarity(t *testing.T) {100 func TestComputeFeedSimilarity(t *testing.T) {
@@ -638,60 +638,153 @@ func TestComputeArticleEmbeddings(t *testing.T) {
638 assert.Equal(t, count, 3, "expected 3 article embeddings")638 assert.Equal(t, count, 3, "expected 3 article embeddings")
639 }639 }
640 640
641-func TestArticleRecommendationsWithContentBoost(t *testing.T) {641+func seedArticleRecData(t *testing.T, ctx context.Context, dbs *db.Store) {
642+ t.Helper()
643+
644+ for _, did := range []string{"did:test:alice", "did:test:bob"} {
645+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, did)
646+ assert.NilError(t, err)
647+ }
648+
649+ for _, f := range []struct{ url, title string }{
650+ {"https://tech.com/feed", "Tech Feed"},
651+ {"https://dev.com/feed", "Dev Feed"},
652+ {"https://shared.com/feed", "Shared Feed"},
653+ } {
654+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`, f.url, f.title, f.url)
655+ assert.NilError(t, err)
656+ }
657+
658+ subs := []struct{ user, feed string }{
659+ {"did:test:alice", "https://tech.com/feed"},
660+ {"did:test:alice", "https://shared.com/feed"},
661+ {"did:test:bob", "https://dev.com/feed"},
662+ {"did:test:bob", "https://shared.com/feed"},
663+ }
664+ for _, s := range subs {
665+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, s.user, s.feed)
666+ assert.NilError(t, err)
667+ }
668+
669+ articles := []struct{ feed, guid, title, summary, url, lang string }{
670+ {"https://tech.com/feed", "1", "golang programming tutorial", "learn go programming", "https://tech.com/go", "en"},
671+ {"https://dev.com/feed", "2", "rust programming tutorial", "learn rust programming", "https://dev.com/rust", "fr"},
672+ {"https://dev.com/feed", "3", "cooking dinner recipes", "easy dinner recipes", "https://dev.com/cook", ""},
673+ {"https://dev.com/feed", "4", "python data science", "python for data analysis", "https://dev.com/python", "de"},
674+ }
675+ for _, a := range articles {
676+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published, language) VALUES (?, ?, ?, ?, ?, datetime('now'), ?)`,
677+ a.feed, a.guid, a.title, a.summary, a.url, a.lang)
678+ assert.NilError(t, err)
679+ }
680+
681+ likes := []struct{ uri, author, feed, article string }{
682+ {"at://alice/like/1", "did:test:alice", "https://tech.com/feed", "https://tech.com/go"},
683+ {"at://bob/like/2", "did:test:bob", "https://dev.com/feed", "https://dev.com/rust"},
684+ {"at://bob/like/3", "did:test:bob", "https://dev.com/feed", "https://dev.com/cook"},
685+ {"at://bob/like/4", "did:test:bob", "https://dev.com/feed", "https://dev.com/python"},
686+ }
687+ for _, l := range likes {
688+ _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
689+ l.uri, l.author, l.feed, l.article)
690+ assert.NilError(t, err)
691+ }
692+}
693+
694+func TestArticleRecommendations_LanguageFilter_ShowsUnclassified(t *testing.T) {
642 ctx := context.Background()695 ctx := context.Background()
643 dbs := setupClusterTestDB(t)696 dbs := setupClusterTestDB(t)
697+ seedArticleRecData(t, ctx, dbs)
644 698
645- _, err := dbs.SQLDB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice")699+ engine := newTestEngine(dbs)
646- assert.NilError(t, err)700+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
647- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob")701+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
648- assert.NilError(t, err)702+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
649 703
650- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,704+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", []string{"en"}, 10)
651- "https://tech.com/feed", "Tech Feed", "https://tech.com")
652- assert.NilError(t, err)
653- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
654- "https://dev.com/feed", "Dev Feed", "https://dev.com")
655- assert.NilError(t, err)
656- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, feed_type, subscriber_count) VALUES (?, ?, ?, 'rss', 2)`,
657- "https://shared.com/feed", "Shared Feed", "https://shared.com")
658 assert.NilError(t, err)705 assert.NilError(t, err)
706+ assert.Assert(t, len(recs) > 0, "alice should get recommendations with language filter")
659 707
660- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://tech.com/feed")708+ urls := make(map[string]bool)
661- assert.NilError(t, err)709+ for _, r := range recs {
662- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://shared.com/feed")710+ urls[r.URL] = true
663- assert.NilError(t, err)711+ }
664- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://dev.com/feed")712+ assert.Assert(t, urls["https://tech.com/go"], "should include article with language 'en'")
665- assert.NilError(t, err)713+ assert.Assert(t, urls["https://dev.com/cook"], "should include article with empty language (unclassified)")
666- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://shared.com/feed")714+ assert.Assert(t, !urls["https://dev.com/rust"], "should exclude article with language 'fr'")
667- assert.NilError(t, err)715+ assert.Assert(t, !urls["https://dev.com/python"], "should exclude article with language 'de'")
716+}
668 717
669- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,718+func TestArticleRecommendations_LanguageFilter_MultipleLanguages(t *testing.T) {
670- "https://tech.com/feed", "1", "golang programming tutorial", "learn go programming", "https://tech.com/go")719+ ctx := context.Background()
671- assert.NilError(t, err)720+ dbs := setupClusterTestDB(t)
672- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,721+ seedArticleRecData(t, ctx, dbs)
673- "https://dev.com/feed", "2", "rust programming tutorial", "learn rust programming", "https://dev.com/rust")
674- assert.NilError(t, err)
675- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, summary, url, published) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
676- "https://dev.com/feed", "3", "cooking dinner recipes", "easy dinner recipes", "https://dev.com/cook")
677- assert.NilError(t, err)
678 722
679- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,723+ engine := newTestEngine(dbs)
680- "at://alice/like/1", "did:test:alice", "https://tech.com/feed", "https://tech.com/go")724+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
681- assert.NilError(t, err)725+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
682- _, err = dbs.SQLDB().ExecContext(ctx, `INSERT INTO articles.likes (uri, author_did, feed_url, article_url, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,726+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
683- "at://bob/like/1", "did:test:bob", "https://dev.com/feed", "https://dev.com/rust")727+
728+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", []string{"en", "fr"}, 10)
684 assert.NilError(t, err)729 assert.NilError(t, err)
730+ assert.Assert(t, len(recs) > 0, "alice should get recommendations with multi-language filter")
731+
732+ urls := make(map[string]bool)
733+ for _, r := range recs {
734+ urls[r.URL] = true
735+ }
736+ assert.Assert(t, urls["https://tech.com/go"], "should include article with language 'en'")
737+ assert.Assert(t, urls["https://dev.com/rust"], "should include article with language 'fr'")
738+ assert.Assert(t, urls["https://dev.com/cook"], "should include article with empty language (unclassified)")
739+ assert.Assert(t, !urls["https://dev.com/python"], "should exclude article with language 'de'")
740+}
741+
742+func TestArticleRecommendations_LanguageFilter_NoPreferences(t *testing.T) {
743+ ctx := context.Background()
744+ dbs := setupClusterTestDB(t)
745+ seedArticleRecData(t, ctx, dbs)
685 746
686 engine := newTestEngine(dbs)747 engine := newTestEngine(dbs)
748+ assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
749+ assert.NilError(t, engine.ComputeUserSimilarity(ctx))
750+ assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
751+
752+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", nil, 10)
753+ assert.NilError(t, err)
754+ assert.Assert(t, len(recs) > 0, "alice should get all recommendations without language filter")
755+
756+ urls := make(map[string]bool)
757+ for _, r := range recs {
758+ urls[r.URL] = true
759+ }
760+ assert.Assert(t, urls["https://tech.com/go"], "should include 'en' article")
761+ assert.Assert(t, urls["https://dev.com/rust"], "should include 'fr' article")
762+ assert.Assert(t, urls["https://dev.com/cook"], "should include unclassified article")
763+ assert.Assert(t, urls["https://dev.com/python"], "should include 'de' article")
764+}
765+
766+func TestArticleRecommendations_LanguageFilter_EmptyPreferences(t *testing.T) {
767+ ctx := context.Background()
768+ dbs := setupClusterTestDB(t)
769+ seedArticleRecData(t, ctx, dbs)
687 770
771+ engine := newTestEngine(dbs)
688 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))772 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
689 assert.NilError(t, engine.ComputeUserSimilarity(ctx))773 assert.NilError(t, engine.ComputeUserSimilarity(ctx))
690 assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))774 assert.NilError(t, engine.ComputeArticleEmbeddings(ctx))
691 775
692- recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", 10)776+ recs, err := engine.GetArticleRecommendations(ctx, "did:test:alice", []string{}, 10)
693 assert.NilError(t, err)777 assert.NilError(t, err)
694- assert.Assert(t, len(recs) > 0, "alice should get article recommendations")778+ assert.Assert(t, len(recs) > 0, "empty language list should show all articles")
779+
780+ urls := make(map[string]bool)
781+ for _, r := range recs {
782+ urls[r.URL] = true
783+ }
784+ assert.Assert(t, urls["https://tech.com/go"], "should include 'en' article")
785+ assert.Assert(t, urls["https://dev.com/rust"], "should include 'fr' article")
786+ assert.Assert(t, urls["https://dev.com/cook"], "should include unclassified article")
787+ assert.Assert(t, urls["https://dev.com/python"], "should include 'de' article")
695 }788 }
696 789
697 func TestFeedEmbeddingRecomputedOnDescriptionChange(t *testing.T) {790 func TestFeedEmbeddingRecomputedOnDescriptionChange(t *testing.T) {
added internal/cluster/llm.go +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/openai/openai-go"
9+ "github.com/openai/openai-go/option"
10+)
11+
12+type LLMClient struct {
13+ client openai.Client
14+ model string
15+}
16+
17+type LLMClientConfig struct {
18+ BaseURL string
19+ APIKey string
20+ Model string
21+}
22+
23+func NewLLMClient(cfg LLMClientConfig) *LLMClient {
24+ opts := []option.RequestOption{}
25+ if cfg.BaseURL != "" {
26+ opts = append(opts, option.WithBaseURL(cfg.BaseURL))
27+ }
28+ if cfg.APIKey != "" {
29+ opts = append(opts, option.WithAPIKey(cfg.APIKey))
30+ }
31+ return &LLMClient{
32+ client: openai.NewClient(opts...),
33+ model: cfg.Model,
34+ }
35+}
36+
37+func (c *LLMClient) DetectLanguages(ctx context.Context, texts []string) ([]string, error) {
38+ var b strings.Builder
39+ b.WriteString("For each text below, respond with ONLY the ISO 639-1 language code (e.g. en, fr, de, es, pt, it, ru, ja, zh, ko, ar). One code per line, same order as input. If uncertain, respond with 'en'.\n\n")
40+ for i, t := range texts {
41+ truncated := t
42+ if len(truncated) > 500 {
43+ truncated = truncated[:500]
44+ }
45+ fmt.Fprintf(&b, "%d. %s\n", i+1, truncated)
46+ }
47+
48+ resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
49+ Model: c.model,
50+ Messages: []openai.ChatCompletionMessageParamUnion{
51+ openai.UserMessage(b.String()),
52+ },
53+ Temperature: openai.Float(0.0),
54+ })
55+ if err != nil {
56+ return nil, err
57+ }
58+
59+ content := resp.Choices[0].Message.Content
60+ lines := strings.Split(strings.TrimSpace(content), "\n")
61+ result := make([]string, len(texts))
62+ for i := range result {
63+ result[i] = "en"
64+ }
65+ for i, line := range lines {
66+ if i >= len(result) {
67+ break
68+ }
69+ code := strings.TrimSpace(line)
70+ code = strings.TrimPrefix(code, fmt.Sprintf("%d.", i+1))
71+ code = strings.TrimSpace(code)
72+ if code != "" {
73+ result[i] = code
74+ }
75+ }
76+ return result, nil
77+}
new file mode 100644
@@ -0,0 +1,77 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/openai/openai-go"
9+ "github.com/openai/openai-go/option"
10+)
11+
12+type LLMClient struct {
13+ client openai.Client
14+ model string
15+}
16+
17+type LLMClientConfig struct {
18+ BaseURL string
19+ APIKey string
20+ Model string
21+}
22+
23+func NewLLMClient(cfg LLMClientConfig) *LLMClient {
24+ opts := []option.RequestOption{}
25+ if cfg.BaseURL != "" {
26+ opts = append(opts, option.WithBaseURL(cfg.BaseURL))
27+ }
28+ if cfg.APIKey != "" {
29+ opts = append(opts, option.WithAPIKey(cfg.APIKey))
30+ }
31+ return &LLMClient{
32+ client: openai.NewClient(opts...),
33+ model: cfg.Model,
34+ }
35+}
36+
37+func (c *LLMClient) DetectLanguages(ctx context.Context, texts []string) ([]string, error) {
38+ var b strings.Builder
39+ b.WriteString("For each text below, respond with ONLY the ISO 639-1 language code (e.g. en, fr, de, es, pt, it, ru, ja, zh, ko, ar). One code per line, same order as input. If uncertain, respond with 'en'.\n\n")
40+ for i, t := range texts {
41+ truncated := t
42+ if len(truncated) > 500 {
43+ truncated = truncated[:500]
44+ }
45+ fmt.Fprintf(&b, "%d. %s\n", i+1, truncated)
46+ }
47+
48+ resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
49+ Model: c.model,
50+ Messages: []openai.ChatCompletionMessageParamUnion{
51+ openai.UserMessage(b.String()),
52+ },
53+ Temperature: openai.Float(0.0),
54+ })
55+ if err != nil {
56+ return nil, err
57+ }
58+
59+ content := resp.Choices[0].Message.Content
60+ lines := strings.Split(strings.TrimSpace(content), "\n")
61+ result := make([]string, len(texts))
62+ for i := range result {
63+ result[i] = "en"
64+ }
65+ for i, line := range lines {
66+ if i >= len(result) {
67+ break
68+ }
69+ code := strings.TrimSpace(line)
70+ code = strings.TrimPrefix(code, fmt.Sprintf("%d.", i+1))
71+ code = strings.TrimSpace(code)
72+ if code != "" {
73+ result[i] = code
74+ }
75+ }
76+ return result, nil
77+}
modified internal/cluster/scoring.go +25 -7
@@ -4,6 +4,7 @@ import (
44 "context"
55 "database/sql"
66 "fmt"
7+ "strings"
78 )
89
910 type FeedRecommendation struct {
@@ -102,8 +103,8 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
102103 // signals (liked by similar users, followed users' feeds), content similarity
103104 // (embedding KNN against user's liked articles), and recency. Scores are
104105 // min-max normalized.
105-func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
106- recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)
106+func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
107+ recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)
107108 if err != nil {
108109 return nil, err
109110 }
@@ -389,7 +390,7 @@ func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, li
389390 return results, nil
390391 }
391392
392-func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
393+func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
393394 w := e.GetWeights(ctx, userDID)
394395
395396 conn, err := e.db.Conn(ctx)
@@ -408,7 +409,18 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
408409 }
409410 }
410411
411- rows, err := conn.QueryContext(ctx, `
412+ langFilter := ""
413+ langArgs := []any{}
414+ if len(languages) > 0 {
415+ ph := make([]string, len(languages))
416+ for i, l := range languages {
417+ ph[i] = "?"
418+ langArgs = append(langArgs, l)
419+ }
420+ langFilter = " AND (a.language IN (" + strings.Join(ph, ",") + ") OR a.language = '')"
421+ }
422+
423+ query := fmt.Sprintf(`
412424 WITH similar_users AS (
413425 SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15
414426 UNION ALL
@@ -453,11 +465,17 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
453465 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url
454466 LEFT JOIN _content_boost cb ON cb.article_id = a.id
455467 LEFT JOIN articles.read_state rs ON rs.article_id = a.id AND rs.user_did = ?
456- WHERE COALESCE(rs.is_read, 0) = 0
468+ WHERE COALESCE(rs.is_read, 0) = 0%s
457469 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC
458470 LIMIT ?
459- `, userDID, userDID, userDID, userDID, userDID, userDID,
460- w.WLike, w.WSocial, w.WContent, userDID, limit)
471+ `, langFilter)
472+
473+ args := []any{userDID, userDID, userDID, userDID, userDID, userDID,
474+ w.WLike, w.WSocial, w.WContent, userDID}
475+ args = append(args, langArgs...)
476+ args = append(args, limit)
477+
478+ rows, err := conn.QueryContext(ctx, query, args...)
461479 if err != nil {
462480 return nil, err
463481 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6 "fmt"6 "fmt"
7+ "strings"
7 )8 )
8 9
9 type FeedRecommendation struct {10 type FeedRecommendation struct {
@@ -102,8 +103,8 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l
102 // signals (liked by similar users, followed users' feeds), content similarity103 // signals (liked by similar users, followed users' feeds), content similarity
103 // (embedding KNN against user's liked articles), and recency. Scores are104 // (embedding KNN against user's liked articles), and recency. Scores are
104 // min-max normalized.105 // min-max normalized.
105-func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {106+func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
106- recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, limit)107+ recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit)
107 if err != nil {108 if err != nil {
108 return nil, err109 return nil, err
109 }110 }
@@ -389,7 +390,7 @@ func (e *Engine) coldStartFromEmbeddings(ctx context.Context, userDID string, li
389 return results, nil390 return results, nil
390 }391 }
391 392
392-func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {393+func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) {
393 w := e.GetWeights(ctx, userDID)394 w := e.GetWeights(ctx, userDID)
394 395
395 conn, err := e.db.Conn(ctx)396 conn, err := e.db.Conn(ctx)
@@ -408,7 +409,18 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
408 }409 }
409 }410 }
410 411
411- rows, err := conn.QueryContext(ctx, `412+ langFilter := ""
413+ langArgs := []any{}
414+ if len(languages) > 0 {
415+ ph := make([]string, len(languages))
416+ for i, l := range languages {
417+ ph[i] = "?"
418+ langArgs = append(langArgs, l)
419+ }
420+ langFilter = " AND (a.language IN (" + strings.Join(ph, ",") + ") OR a.language = '')"
421+ }
422+
423+ query := fmt.Sprintf(`
412 WITH similar_users AS (424 WITH similar_users AS (
413 SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15425 SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15
414 UNION ALL426 UNION ALL
@@ -453,11 +465,17 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user
453 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url465 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url
454 LEFT JOIN _content_boost cb ON cb.article_id = a.id466 LEFT JOIN _content_boost cb ON cb.article_id = a.id
455 LEFT JOIN articles.read_state rs ON rs.article_id = a.id AND rs.user_did = ?467 LEFT JOIN articles.read_state rs ON rs.article_id = a.id AND rs.user_did = ?
456- WHERE COALESCE(rs.is_read, 0) = 0468+ WHERE COALESCE(rs.is_read, 0) = 0%s
457 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC469 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC
458 LIMIT ?470 LIMIT ?
459- `, userDID, userDID, userDID, userDID, userDID, userDID,471+ `, langFilter)
460- w.WLike, w.WSocial, w.WContent, userDID, limit)472+
473+ args := []any{userDID, userDID, userDID, userDID, userDID, userDID,
474+ w.WLike, w.WSocial, w.WContent, userDID}
475+ args = append(args, langArgs...)
476+ args = append(args, limit)
477+
478+ rows, err := conn.QueryContext(ctx, query, args...)
461 if err != nil {479 if err != nil {
462 return nil, err480 return nil, err
463 }481 }
modified internal/db/article.go +2 -2
@@ -63,8 +63,8 @@ func (s *ArticleStore) BatchUpsertArticles(ctx context.Context, articles []feed.
6363 defer tx.Rollback()
6464
6565 stmt, err := tx.PrepareContext(ctx, `
66- INSERT INTO articles.articles (feed_url, guid, title, url, author, summary, content, published, updated)
67- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
66+ INSERT INTO articles.articles (feed_url, guid, title, url, author, summary, content, published, updated, language)
67+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '')
6868 ON CONFLICT(feed_url, guid) DO NOTHING
6969 `)
7070 if err != nil {
@@ -63,8 +63,8 @@ func (s *ArticleStore) BatchUpsertArticles(ctx context.Context, articles []feed.
63 defer tx.Rollback()63 defer tx.Rollback()
64 64
65 stmt, err := tx.PrepareContext(ctx, `65 stmt, err := tx.PrepareContext(ctx, `
66- INSERT INTO articles.articles (feed_url, guid, title, url, author, summary, content, published, updated)66+ INSERT INTO articles.articles (feed_url, guid, title, url, author, summary, content, published, updated, language)
67- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)67+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '')
68 ON CONFLICT(feed_url, guid) DO NOTHING68 ON CONFLICT(feed_url, guid) DO NOTHING
69 `)69 `)
70 if err != nil {70 if err != nil {
modified internal/db/db.go +8 -0
@@ -236,6 +236,12 @@ var usersSchema = []string{
236236 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
237237 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
238238
239+ `CREATE TABLE IF NOT EXISTS user_settings (
240+ did TEXT PRIMARY KEY,
241+ languages TEXT,
242+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
243+ )`,
244+
239245 `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
240246 user_did TEXT NOT NULL,
241247 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')),
@@ -301,6 +307,7 @@ var articlesSchema = []string{
301307 published DATETIME,
302308 updated DATETIME,
303309 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
310+ language TEXT NOT NULL DEFAULT '',
304311 UNIQUE(feed_url, guid)
305312 )`,
306313
@@ -353,6 +360,7 @@ var articlesSchema = []string{
353360 `CREATE INDEX IF NOT EXISTS articles.idx_likes_article ON likes(feed_url, article_url)`,
354361 `CREATE INDEX IF NOT EXISTS articles.idx_likes_author ON likes(author_did)`,
355362 `CREATE INDEX IF NOT EXISTS articles.idx_likes_created_at ON likes(created_at DESC)`,
363+ `CREATE INDEX IF NOT EXISTS articles.idx_articles_language ON articles(language)`,
356364
357365 `CREATE VIRTUAL TABLE IF NOT EXISTS articles.articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
358366 `CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN
@@ -236,6 +236,12 @@ var usersSchema = []string{
236 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,236 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
237 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,237 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
238 238
239+ `CREATE TABLE IF NOT EXISTS user_settings (
240+ did TEXT PRIMARY KEY,
241+ languages TEXT,
242+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
243+ )`,
244+
239 `CREATE TABLE IF NOT EXISTS dismissed_recommendations (245 `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
240 user_did TEXT NOT NULL,246 user_did TEXT NOT NULL,
241 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')),247 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')),
@@ -301,6 +307,7 @@ var articlesSchema = []string{
301 published DATETIME,307 published DATETIME,
302 updated DATETIME,308 updated DATETIME,
303 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,309 fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
310+ language TEXT NOT NULL DEFAULT '',
304 UNIQUE(feed_url, guid)311 UNIQUE(feed_url, guid)
305 )`,312 )`,
306 313
@@ -353,6 +360,7 @@ var articlesSchema = []string{
353 `CREATE INDEX IF NOT EXISTS articles.idx_likes_article ON likes(feed_url, article_url)`,360 `CREATE INDEX IF NOT EXISTS articles.idx_likes_article ON likes(feed_url, article_url)`,
354 `CREATE INDEX IF NOT EXISTS articles.idx_likes_author ON likes(author_did)`,361 `CREATE INDEX IF NOT EXISTS articles.idx_likes_author ON likes(author_did)`,
355 `CREATE INDEX IF NOT EXISTS articles.idx_likes_created_at ON likes(created_at DESC)`,362 `CREATE INDEX IF NOT EXISTS articles.idx_likes_created_at ON likes(created_at DESC)`,
363+ `CREATE INDEX IF NOT EXISTS articles.idx_articles_language ON articles(language)`,
356 364
357 `CREATE VIRTUAL TABLE IF NOT EXISTS articles.articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,365 `CREATE VIRTUAL TABLE IF NOT EXISTS articles.articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
358 `CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN366 `CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN
modified internal/db/migrations.go +33 -1
@@ -13,7 +13,7 @@ func init() {
1313 }
1414
1515 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).
16-const SchemaVersion = 2
16+const SchemaVersion = 3
1717
1818 type migration struct {
1919 id int
@@ -32,6 +32,11 @@ var migrations = []migration{
3232 name: "feed_type_atproto",
3333 run: migrateFeedTypeATProto,
3434 },
35+ {
36+ id: 3,
37+ name: "article_language_user_languages",
38+ run: migrateArticleLanguageUserLanguages,
39+ },
3540 }
3641
3742 func runMigrations(db *DB) error {
@@ -181,3 +186,30 @@ func migrateAddPersonTargetType(db *DB) error {
181186
182187 return nil
183188 }
189+
190+func migrateArticleLanguageUserLanguages(db *DB) error {
191+ var colCount int
192+ err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('articles.articles') WHERE name='language'").Scan(&colCount)
193+ if err != nil {
194+ return fmt.Errorf("check articles.language column: %w", err)
195+ }
196+ if colCount == 0 {
197+ if _, err := db.Exec("ALTER TABLE articles.articles ADD COLUMN language TEXT NOT NULL DEFAULT ''"); err != nil {
198+ return fmt.Errorf("add articles.language: %w", err)
199+ }
200+ if _, err := db.Exec("CREATE INDEX IF NOT EXISTS articles.idx_articles_language ON articles(language)"); err != nil {
201+ return fmt.Errorf("create articles.language index: %w", err)
202+ }
203+ }
204+
205+ _, err = db.Exec(`CREATE TABLE IF NOT EXISTS user_settings (
206+ did TEXT PRIMARY KEY,
207+ languages TEXT,
208+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
209+ )`)
210+ if err != nil {
211+ return fmt.Errorf("create user_settings: %w", err)
212+ }
213+
214+ return nil
215+}
@@ -13,7 +13,7 @@ func init() {
13 }13 }
14 14
15 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).15 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).
16-const SchemaVersion = 216+const SchemaVersion = 3
17 17
18 type migration struct {18 type migration struct {
19 id int19 id int
@@ -32,6 +32,11 @@ var migrations = []migration{
32 name: "feed_type_atproto",32 name: "feed_type_atproto",
33 run: migrateFeedTypeATProto,33 run: migrateFeedTypeATProto,
34 },34 },
35+ {
36+ id: 3,
37+ name: "article_language_user_languages",
38+ run: migrateArticleLanguageUserLanguages,
39+ },
35 }40 }
36 41
37 func runMigrations(db *DB) error {42 func runMigrations(db *DB) error {
@@ -181,3 +186,30 @@ func migrateAddPersonTargetType(db *DB) error {
181 186
182 return nil187 return nil
183 }188 }
189+
190+func migrateArticleLanguageUserLanguages(db *DB) error {
191+ var colCount int
192+ err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('articles.articles') WHERE name='language'").Scan(&colCount)
193+ if err != nil {
194+ return fmt.Errorf("check articles.language column: %w", err)
195+ }
196+ if colCount == 0 {
197+ if _, err := db.Exec("ALTER TABLE articles.articles ADD COLUMN language TEXT NOT NULL DEFAULT ''"); err != nil {
198+ return fmt.Errorf("add articles.language: %w", err)
199+ }
200+ if _, err := db.Exec("CREATE INDEX IF NOT EXISTS articles.idx_articles_language ON articles(language)"); err != nil {
201+ return fmt.Errorf("create articles.language index: %w", err)
202+ }
203+ }
204+
205+ _, err = db.Exec(`CREATE TABLE IF NOT EXISTS user_settings (
206+ did TEXT PRIMARY KEY,
207+ languages TEXT,
208+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
209+ )`)
210+ if err != nil {
211+ return fmt.Errorf("create user_settings: %w", err)
212+ }
213+
214+ return nil
215+}
added internal/db/user_settings.go +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "strings"
7+)
8+
9+type UserSettings struct {
10+ DID string
11+ Languages []string
12+}
13+
14+func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) {
15+ var langs sql.NullString
16+ err := s.db.QueryRowContext(ctx, `SELECT languages FROM user_settings WHERE did = ?`, did).Scan(&langs)
17+ if err != nil {
18+ if err == sql.ErrNoRows {
19+ return &UserSettings{DID: did}, nil
20+ }
21+ return nil, err
22+ }
23+ us := &UserSettings{DID: did}
24+ if langs.Valid && langs.String != "" {
25+ us.Languages = strings.Split(langs.String, ",")
26+ }
27+ return us, nil
28+}
29+
30+func (s *UserStore) UpdateLanguages(ctx context.Context, did string, languages []string) error {
31+ langStr := strings.Join(languages, ",")
32+ _, err := s.db.ExecContext(ctx, `
33+ INSERT INTO user_settings (did, languages, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
34+ ON CONFLICT(did) DO UPDATE SET languages = excluded.languages, updated_at = CURRENT_TIMESTAMP
35+ `, did, langStr)
36+ return err
37+}
38+
39+func (s *UserStore) GetLanguages(ctx context.Context, did string) ([]string, error) {
40+ us, err := s.GetSettings(ctx, did)
41+ if err != nil {
42+ return nil, err
43+ }
44+ if len(us.Languages) == 0 {
45+ return nil, nil
46+ }
47+ return us.Languages, nil
48+}
new file mode 100644
@@ -0,0 +1,48 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "strings"
7+)
8+
9+type UserSettings struct {
10+ DID string
11+ Languages []string
12+}
13+
14+func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) {
15+ var langs sql.NullString
16+ err := s.db.QueryRowContext(ctx, `SELECT languages FROM user_settings WHERE did = ?`, did).Scan(&langs)
17+ if err != nil {
18+ if err == sql.ErrNoRows {
19+ return &UserSettings{DID: did}, nil
20+ }
21+ return nil, err
22+ }
23+ us := &UserSettings{DID: did}
24+ if langs.Valid && langs.String != "" {
25+ us.Languages = strings.Split(langs.String, ",")
26+ }
27+ return us, nil
28+}
29+
30+func (s *UserStore) UpdateLanguages(ctx context.Context, did string, languages []string) error {
31+ langStr := strings.Join(languages, ",")
32+ _, err := s.db.ExecContext(ctx, `
33+ INSERT INTO user_settings (did, languages, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
34+ ON CONFLICT(did) DO UPDATE SET languages = excluded.languages, updated_at = CURRENT_TIMESTAMP
35+ `, did, langStr)
36+ return err
37+}
38+
39+func (s *UserStore) GetLanguages(ctx context.Context, did string) ([]string, error) {
40+ us, err := s.GetSettings(ctx, did)
41+ if err != nil {
42+ return nil, err
43+ }
44+ if len(us.Languages) == 0 {
45+ return nil, nil
46+ }
47+ return us.Languages, nil
48+}
modified internal/db/user_test.go +60 -0
@@ -19,3 +19,63 @@ func TestGetUser(t *testing.T) {
1919 assert.NilError(t, err)
2020 assert.Equal(t, got.DID, "did:test:profile")
2121 }
22+
23+func TestUpdateLanguages(t *testing.T) {
24+ ctx := context.Background()
25+ dbs := setupTestDB(t)
26+
27+ _, err := dbs.Users.CreateUser(ctx, "did:test:langs")
28+ assert.NilError(t, err)
29+
30+ err = dbs.Users.UpdateLanguages(ctx, "did:test:langs", []string{"en", "fr", "de"})
31+ assert.NilError(t, err)
32+
33+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:langs")
34+ assert.NilError(t, err)
35+ assert.DeepEqual(t, langs, []string{"en", "fr", "de"})
36+}
37+
38+func TestGetLanguages_Empty(t *testing.T) {
39+ ctx := context.Background()
40+ dbs := setupTestDB(t)
41+
42+ _, err := dbs.Users.CreateUser(ctx, "did:test:nolangs")
43+ assert.NilError(t, err)
44+
45+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:nolangs")
46+ assert.NilError(t, err)
47+ assert.Assert(t, langs == nil)
48+}
49+
50+func TestGetLanguages_Set(t *testing.T) {
51+ ctx := context.Background()
52+ dbs := setupTestDB(t)
53+
54+ _, err := dbs.Users.CreateUser(ctx, "did:test:langset")
55+ assert.NilError(t, err)
56+
57+ err = dbs.Users.UpdateLanguages(ctx, "did:test:langset", []string{"en", "ja"})
58+ assert.NilError(t, err)
59+
60+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:langset")
61+ assert.NilError(t, err)
62+ assert.DeepEqual(t, langs, []string{"en", "ja"})
63+}
64+
65+func TestGetLanguages_ClearToEmpty(t *testing.T) {
66+ ctx := context.Background()
67+ dbs := setupTestDB(t)
68+
69+ _, err := dbs.Users.CreateUser(ctx, "did:test:clearlangs")
70+ assert.NilError(t, err)
71+
72+ err = dbs.Users.UpdateLanguages(ctx, "did:test:clearlangs", []string{"en"})
73+ assert.NilError(t, err)
74+
75+ err = dbs.Users.UpdateLanguages(ctx, "did:test:clearlangs", nil)
76+ assert.NilError(t, err)
77+
78+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:clearlangs")
79+ assert.NilError(t, err)
80+ assert.Assert(t, langs == nil)
81+}
@@ -19,3 +19,63 @@ func TestGetUser(t *testing.T) {
19 assert.NilError(t, err)19 assert.NilError(t, err)
20 assert.Equal(t, got.DID, "did:test:profile")20 assert.Equal(t, got.DID, "did:test:profile")
21 }21 }
22+
23+func TestUpdateLanguages(t *testing.T) {
24+ ctx := context.Background()
25+ dbs := setupTestDB(t)
26+
27+ _, err := dbs.Users.CreateUser(ctx, "did:test:langs")
28+ assert.NilError(t, err)
29+
30+ err = dbs.Users.UpdateLanguages(ctx, "did:test:langs", []string{"en", "fr", "de"})
31+ assert.NilError(t, err)
32+
33+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:langs")
34+ assert.NilError(t, err)
35+ assert.DeepEqual(t, langs, []string{"en", "fr", "de"})
36+}
37+
38+func TestGetLanguages_Empty(t *testing.T) {
39+ ctx := context.Background()
40+ dbs := setupTestDB(t)
41+
42+ _, err := dbs.Users.CreateUser(ctx, "did:test:nolangs")
43+ assert.NilError(t, err)
44+
45+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:nolangs")
46+ assert.NilError(t, err)
47+ assert.Assert(t, langs == nil)
48+}
49+
50+func TestGetLanguages_Set(t *testing.T) {
51+ ctx := context.Background()
52+ dbs := setupTestDB(t)
53+
54+ _, err := dbs.Users.CreateUser(ctx, "did:test:langset")
55+ assert.NilError(t, err)
56+
57+ err = dbs.Users.UpdateLanguages(ctx, "did:test:langset", []string{"en", "ja"})
58+ assert.NilError(t, err)
59+
60+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:langset")
61+ assert.NilError(t, err)
62+ assert.DeepEqual(t, langs, []string{"en", "ja"})
63+}
64+
65+func TestGetLanguages_ClearToEmpty(t *testing.T) {
66+ ctx := context.Background()
67+ dbs := setupTestDB(t)
68+
69+ _, err := dbs.Users.CreateUser(ctx, "did:test:clearlangs")
70+ assert.NilError(t, err)
71+
72+ err = dbs.Users.UpdateLanguages(ctx, "did:test:clearlangs", []string{"en"})
73+ assert.NilError(t, err)
74+
75+ err = dbs.Users.UpdateLanguages(ctx, "did:test:clearlangs", nil)
76+ assert.NilError(t, err)
77+
78+ langs, err := dbs.Users.GetLanguages(ctx, "did:test:clearlangs")
79+ assert.NilError(t, err)
80+ assert.Assert(t, langs == nil)
81+}
added internal/langdetect/langdetect.go +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+package langdetect
2+
3+type Language struct {
4+ Code string
5+ Name string
6+}
7+
8+var knownLanguages = []Language{
9+ {"en", "English"},
10+ {"fr", "French"},
11+ {"de", "German"},
12+ {"es", "Spanish"},
13+ {"pt", "Portuguese"},
14+ {"it", "Italian"},
15+ {"ru", "Russian"},
16+ {"ja", "Japanese"},
17+ {"zh", "Chinese"},
18+ {"ko", "Korean"},
19+ {"ar", "Arabic"},
20+}
21+
22+func KnownLanguages() []Language {
23+ return knownLanguages
24+}
new file mode 100644
@@ -0,0 +1,24 @@
1+package langdetect
2+
3+type Language struct {
4+ Code string
5+ Name string
6+}
7+
8+var knownLanguages = []Language{
9+ {"en", "English"},
10+ {"fr", "French"},
11+ {"de", "German"},
12+ {"es", "Spanish"},
13+ {"pt", "Portuguese"},
14+ {"it", "Italian"},
15+ {"ru", "Russian"},
16+ {"ja", "Japanese"},
17+ {"zh", "Chinese"},
18+ {"ko", "Korean"},
19+ {"ar", "Arabic"},
20+}
21+
22+func KnownLanguages() []Language {
23+ return knownLanguages
24+}
added internal/langdetect/langdetect_test.go +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+package langdetect
2+
3+import (
4+ "testing"
5+
6+ "gotest.tools/v3/assert"
7+)
8+
9+func TestKnownLanguages(t *testing.T) {
10+ langs := KnownLanguages()
11+ assert.Assert(t, len(langs) > 0)
12+ found := false
13+ for _, l := range langs {
14+ if l.Code == "en" {
15+ found = true
16+ assert.Equal(t, l.Name, "English")
17+ }
18+ }
19+ assert.Assert(t, found)
20+}
new file mode 100644
@@ -0,0 +1,20 @@
1+package langdetect
2+
3+import (
4+ "testing"
5+
6+ "gotest.tools/v3/assert"
7+)
8+
9+func TestKnownLanguages(t *testing.T) {
10+ langs := KnownLanguages()
11+ assert.Assert(t, len(langs) > 0)
12+ found := false
13+ for _, l := range langs {
14+ if l.Code == "en" {
15+ found = true
16+ assert.Equal(t, l.Name, "English")
17+ }
18+ }
19+ assert.Assert(t, found)
20+}
modified internal/server/dashboard_handler.go +6 -1
@@ -36,7 +36,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
3636 articles = articles[:page.PageSize]
3737 }
3838
39- articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, 5)
39+ userLangs, err := s.dbs.Users.GetLanguages(ctx, user.DID)
40+ if err != nil {
41+ s.logger.Warn("failed to get user languages", "error", err, "did", user.DID)
42+ }
43+
44+ articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)
4045 if err != nil {
4146 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)
4247 }
@@ -36,7 +36,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
36 articles = articles[:page.PageSize]36 articles = articles[:page.PageSize]
37 }37 }
38 38
39- articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, 5)39+ userLangs, err := s.dbs.Users.GetLanguages(ctx, user.DID)
40+ if err != nil {
41+ s.logger.Warn("failed to get user languages", "error", err, "did", user.DID)
42+ }
43+
44+ articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)
40 if err != nil {45 if err != nil {
41 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)46 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)
42 }47 }
modified internal/server/profile_handler.go +12 -7
@@ -7,6 +7,7 @@ import (
77 "github.com/go-chi/chi/v5"
88
99 "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/langdetect"
1011 )
1112
1213 func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
@@ -56,13 +57,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
5657
5758 user := currentUser(r)
5859
60+ userLangs, _ := s.dbs.Users.GetLanguages(ctx, user.DID)
61+
5962 s.render(w, r, "profile.html", map[string]any{
60- "User": user,
61- "CurrentUserDID": user.DID,
62- "ProfileUser": profileUser,
63- "Subscriptions": subs,
64- "Annotations": annotations,
65- "SubscriptionCount": subCount,
66- "AnnotationCount": len(annotations),
63+ "User": user,
64+ "CurrentUserDID": user.DID,
65+ "ProfileUser": profileUser,
66+ "Subscriptions": subs,
67+ "Annotations": annotations,
68+ "SubscriptionCount": subCount,
69+ "AnnotationCount": len(annotations),
70+ "UserLanguages": userLangs,
71+ "AvailableLanguages": langdetect.KnownLanguages(),
6772 })
6873 }
@@ -7,6 +7,7 @@ import (
7 "github.com/go-chi/chi/v5"7 "github.com/go-chi/chi/v5"
8 8
9 "pkg.rbrt.fr/glean/internal/atproto"9 "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/langdetect"
10 )11 )
11 12
12 func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {13 func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
@@ -56,13 +57,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
56 57
57 user := currentUser(r)58 user := currentUser(r)
58 59
60+ userLangs, _ := s.dbs.Users.GetLanguages(ctx, user.DID)
61+
59 s.render(w, r, "profile.html", map[string]any{62 s.render(w, r, "profile.html", map[string]any{
60- "User": user,63+ "User": user,
61- "CurrentUserDID": user.DID,64+ "CurrentUserDID": user.DID,
62- "ProfileUser": profileUser,65+ "ProfileUser": profileUser,
63- "Subscriptions": subs,66+ "Subscriptions": subs,
64- "Annotations": annotations,67+ "Annotations": annotations,
65- "SubscriptionCount": subCount,68+ "SubscriptionCount": subCount,
66- "AnnotationCount": len(annotations),69+ "AnnotationCount": len(annotations),
70+ "UserLanguages": userLangs,
71+ "AvailableLanguages": langdetect.KnownLanguages(),
67 })72 })
68 }73 }
modified internal/server/server.go +12 -0
@@ -203,6 +203,11 @@ func (s *Server) setupRoutes() {
203203 r.Post("/dismiss-person", s.handleDismissPersonRecommendation)
204204 })
205205
206+ s.router.Route("/settings", func(r chi.Router) {
207+ r.Use(s.requireAuth)
208+ r.Post("/languages", s.handleUpdateLanguages)
209+ })
210+
206211 s.router.Get("/auth/login", s.handleAuthLogin)
207212 s.router.Get("/auth/register", s.handleAuthRegister)
208213 s.router.Get("/auth/resolve", s.handleAuthResolve)
@@ -354,6 +359,13 @@ func (s *Server) loadTemplates() {
354359 u.RawQuery = q.Encode()
355360 return u.String()
356361 },
362+ "containsString": func(slice any, s string) bool {
363+ sl, ok := slice.([]string)
364+ if !ok {
365+ return false
366+ }
367+ return slices.Contains(sl, s)
368+ },
357369 }
358370
359371 var err error
@@ -203,6 +203,11 @@ func (s *Server) setupRoutes() {
203 r.Post("/dismiss-person", s.handleDismissPersonRecommendation)203 r.Post("/dismiss-person", s.handleDismissPersonRecommendation)
204 })204 })
205 205
206+ s.router.Route("/settings", func(r chi.Router) {
207+ r.Use(s.requireAuth)
208+ r.Post("/languages", s.handleUpdateLanguages)
209+ })
210+
206 s.router.Get("/auth/login", s.handleAuthLogin)211 s.router.Get("/auth/login", s.handleAuthLogin)
207 s.router.Get("/auth/register", s.handleAuthRegister)212 s.router.Get("/auth/register", s.handleAuthRegister)
208 s.router.Get("/auth/resolve", s.handleAuthResolve)213 s.router.Get("/auth/resolve", s.handleAuthResolve)
@@ -354,6 +359,13 @@ func (s *Server) loadTemplates() {
354 u.RawQuery = q.Encode()359 u.RawQuery = q.Encode()
355 return u.String()360 return u.String()
356 },361 },
362+ "containsString": func(slice any, s string) bool {
363+ sl, ok := slice.([]string)
364+ if !ok {
365+ return false
366+ }
367+ return slices.Contains(sl, s)
368+ },
357 }369 }
358 370
359 var err error371 var err error
added internal/server/settings_handler.go +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+package server
2+
3+import (
4+ "net/http"
5+ "strings"
6+
7+ "pkg.rbrt.fr/glean/internal/langdetect"
8+)
9+
10+func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) {
11+ user := currentUser(r)
12+
13+ if err := r.ParseForm(); err != nil {
14+ http.Error(w, err.Error(), http.StatusBadRequest)
15+ return
16+ }
17+
18+ valid := make(map[string]bool)
19+ for _, known := range langdetect.KnownLanguages() {
20+ valid[known.Code] = true
21+ }
22+
23+ var filtered []string
24+ for _, l := range r.Form["languages"] {
25+ l = strings.TrimSpace(l)
26+ if valid[l] {
27+ filtered = append(filtered, l)
28+ }
29+ }
30+
31+ if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, filtered); err != nil {
32+ s.logger.Error("failed to update languages", "error", err)
33+ http.Error(w, err.Error(), http.StatusInternalServerError)
34+ return
35+ }
36+
37+ w.Header().Set("HX-Redirect", "/profile/"+user.DID)
38+ w.WriteHeader(http.StatusOK)
39+}
new file mode 100644
@@ -0,0 +1,39 @@
1+package server
2+
3+import (
4+ "net/http"
5+ "strings"
6+
7+ "pkg.rbrt.fr/glean/internal/langdetect"
8+)
9+
10+func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) {
11+ user := currentUser(r)
12+
13+ if err := r.ParseForm(); err != nil {
14+ http.Error(w, err.Error(), http.StatusBadRequest)
15+ return
16+ }
17+
18+ valid := make(map[string]bool)
19+ for _, known := range langdetect.KnownLanguages() {
20+ valid[known.Code] = true
21+ }
22+
23+ var filtered []string
24+ for _, l := range r.Form["languages"] {
25+ l = strings.TrimSpace(l)
26+ if valid[l] {
27+ filtered = append(filtered, l)
28+ }
29+ }
30+
31+ if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, filtered); err != nil {
32+ s.logger.Error("failed to update languages", "error", err)
33+ http.Error(w, err.Error(), http.StatusInternalServerError)
34+ return
35+ }
36+
37+ w.Header().Set("HX-Redirect", "/profile/"+user.DID)
38+ w.WriteHeader(http.StatusOK)
39+}
modified internal/tmpl/profile.html +33 -0
@@ -22,6 +22,39 @@
2222 </div>
2323 </div>
2424
25+ {{if eq .ProfileUser.DID .CurrentUserDID}}
26+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>
27+ <div class="bg-spot-surface rounded-xl p-5 mb-6">
28+ <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()">
29+ <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
30+ <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>
31+ <div class="flex flex-wrap gap-2 mb-4">
32+ {{range .AvailableLanguages}}
33+ <label class="lang-pill cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
34+ <input type="checkbox" name="languages" value="{{.Code}}" {{if containsString $.UserLanguages .Code}}checked{{end}} class="hidden">
35+ {{.Name}}
36+ </label>
37+ {{end}}
38+ </div>
39+ <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
40+ </form>
41+ </div>
42+ <script>
43+ document.querySelectorAll('#lang-form .lang-pill input[type=checkbox]').forEach(function(cb) {
44+ cb.addEventListener('change', function() {
45+ var pill = this.closest('.lang-pill');
46+ if (this.checked) {
47+ pill.classList.remove('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
48+ pill.classList.add('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
49+ } else {
50+ pill.classList.remove('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
51+ pill.classList.add('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
52+ }
53+ });
54+ });
55+ </script>
56+ {{end}}
57+
2558 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>
2659 <div class="bg-spot-surface rounded-xl divide-y divide-spot-divider mb-6">
2760 {{range .Subscriptions}}
@@ -22,6 +22,39 @@
22 </div>22 </div>
23 </div>23 </div>
24 24
25+ {{if eq .ProfileUser.DID .CurrentUserDID}}
26+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>
27+ <div class="bg-spot-surface rounded-xl p-5 mb-6">
28+ <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()">
29+ <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
30+ <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>
31+ <div class="flex flex-wrap gap-2 mb-4">
32+ {{range .AvailableLanguages}}
33+ <label class="lang-pill cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
34+ <input type="checkbox" name="languages" value="{{.Code}}" {{if containsString $.UserLanguages .Code}}checked{{end}} class="hidden">
35+ {{.Name}}
36+ </label>
37+ {{end}}
38+ </div>
39+ <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
40+ </form>
41+ </div>
42+ <script>
43+ document.querySelectorAll('#lang-form .lang-pill input[type=checkbox]').forEach(function(cb) {
44+ cb.addEventListener('change', function() {
45+ var pill = this.closest('.lang-pill');
46+ if (this.checked) {
47+ pill.classList.remove('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
48+ pill.classList.add('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
49+ } else {
50+ pill.classList.remove('bg-spot-active-pill-bg', 'text-spot-active-pill-text');
51+ pill.classList.add('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text');
52+ }
53+ });
54+ });
55+ </script>
56+ {{end}}
57+
25 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>58 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>
26 <div class="bg-spot-surface rounded-xl divide-y divide-spot-divider mb-6">59 <div class="bg-spot-surface rounded-xl divide-y divide-spot-divider mb-6">
27 {{range .Subscriptions}}60 {{range .Subscriptions}}
modified main.go +10 -1
@@ -74,7 +74,16 @@ func main() {
7474 }
7575 }
7676
77- engine := cluster.NewEngine(dbs.SQLDB(), embedder, logger)
77+ var llm *cluster.LLMClient
78+ if llmURL := envOr("GLEAN_LLM_BASE_URL", ""); llmURL != "" {
79+ llm = cluster.NewLLMClient(cluster.LLMClientConfig{
80+ BaseURL: llmURL,
81+ APIKey: envOr("GLEAN_LLM_API_KEY", ""),
82+ Model: envOr("GLEAN_LLM_MODEL", "gpt-4o-mini"),
83+ })
84+ }
85+
86+ engine := cluster.NewEngine(dbs.SQLDB(), embedder, llm, logger)
7887
7988 fetcher := feed.NewFetcher(siteFetcher)
8089 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
@@ -74,7 +74,16 @@ func main() {
74 }74 }
75 }75 }
76 76
77- engine := cluster.NewEngine(dbs.SQLDB(), embedder, logger)77+ var llm *cluster.LLMClient
78+ if llmURL := envOr("GLEAN_LLM_BASE_URL", ""); llmURL != "" {
79+ llm = cluster.NewLLMClient(cluster.LLMClientConfig{
80+ BaseURL: llmURL,
81+ APIKey: envOr("GLEAN_LLM_API_KEY", ""),
82+ Model: envOr("GLEAN_LLM_MODEL", "gpt-4o-mini"),
83+ })
84+ }
85+
86+ engine := cluster.NewEngine(dbs.SQLDB(), embedder, llm, logger)
78 87
79 fetcher := feed.NewFetcher(siteFetcher)88 fetcher := feed.NewFetcher(siteFetcher)
80 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))89 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
modified readme.md +3 -0
@@ -69,6 +69,9 @@ Then open `http://localhost:8080`.
6969 | `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
7070 | `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
7171 | `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
72+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection (see below) |
73+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
74+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
7275
7376 For production:
7477
@@ -69,6 +69,9 @@ Then open `http://localhost:8080`.
69 | `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |69 | `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
70 | `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |70 | `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
71 | `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |71 | `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
72+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection (see below) |
73+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
74+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
72 75
73 For production:76 For production:
74 77