Summary
A user's saved-search subscriptions bypass both the "Topics to Avoid" keyword list and the "Avoid disturbing news" toggle. An article that matches a saved search gets injected into the home feed even when it also matches an avoid-keyword and/or is classified as disturbing.
Concrete example
A user (Danish learner) had these settings:
- Topics to Avoid:
bitcoin, blockchain, trump, Trumps
- Avoid disturbing news (violence, death, disasters): ✅ enabled
- A saved search subscription for
iran
Their top feed card was:
"Trump truer Iran med massivt missilangreb efter påstande om attentatplaner" (dr.dk, Politics, 🔍 iran)
This is exactly the kind of article both filters exist to suppress — the title contains the trump token, and it's clearly disturbing (missile strike, assassination plots). It appeared anyway because it arrived via the iran search injection.
Root cause
article_recommendations_for_user (zeeguu/core/content_recommender/elastic_recommender.py) builds the feed in two independent halves:
1. Organic recommendations — build_elastic_recommender_query (elastic_query_builder.py). This honors both filters:
- avoid-keywords →
must_not on title/content (elastic_query_builder.py:124-126)
- disturbing →
must_not: {is_disturbing: true} when enabled (elastic_query_builder.py:156-157)
2. Saved-search injection — loop at elastic_recommender.py:192-210 calls article_and_video_search_for_user → build_elastic_search_query (elastic_query_builder.py:297). That builder filters only by search term + language + CEFR + recency. It has no avoid-keyword must_not and no disturbing must_not.
Notably, article_and_video_search_for_user (elastic_recommender.py:318-326) already computes unwanted_user_searches and filter_disturbing via _prepare_user_constraints — then never passes them to the query builder. They're silently dropped.
Because injected search articles are recent, they sort to the top by published_time and the client features one as the top card.
Other paths with the same gap (for consideration)
topic_filter_for_user (elastic_recommender.py:364) — single-topic feed, no avoid-keyword exclusion.
- Standalone
/search and /latest_search endpoints — use build_elastic_search_query, so no avoid filtering (arguably fine for an explicit one-off search, less fine for a persisted subscription).
- DB fallback in
/user_articles/recommended (when ES is down) — plain SQL, no avoid filtering.
Open design question (why we're filing rather than just patching)
What should win when a user's avoid-list and their search-subscription conflict? Options to weigh:
- Avoid-list always wins — apply
unwanted_user_searches + filter_disturbing must_not to the search-injection query too. Simplest; matches the intent that "avoid" is a hard block. Downside: a subscription for iran silently drops overlapping items with no explanation.
- Disturbing wins, keywords don't — a subscription is an explicit opt-in to a topic, so honor keyword overlap but still never inject disturbing content. Splits the two filters.
- Warn/surface — inject but visibly flag "matches your avoid list", let the user decide.
Leaning toward at least (1) for the disturbing filter (a safety setting shouldn't be defeated by a topic subscription), and probably for keywords too — but worth a discussion before implementing.
Suggested fix (if we go with option 1)
Thread unwanted_user_searches and filter_disturbing into build_elastic_search_query and add the same two must_not clauses the organic recommender already uses.
Summary
A user's saved-search subscriptions bypass both the "Topics to Avoid" keyword list and the "Avoid disturbing news" toggle. An article that matches a saved search gets injected into the home feed even when it also matches an avoid-keyword and/or is classified as disturbing.
Concrete example
A user (Danish learner) had these settings:
bitcoin,blockchain,trump,TrumpsiranTheir top feed card was:
This is exactly the kind of article both filters exist to suppress — the title contains the
trumptoken, and it's clearly disturbing (missile strike, assassination plots). It appeared anyway because it arrived via theiransearch injection.Root cause
article_recommendations_for_user(zeeguu/core/content_recommender/elastic_recommender.py) builds the feed in two independent halves:1. Organic recommendations —
build_elastic_recommender_query(elastic_query_builder.py). This honors both filters:must_noton title/content (elastic_query_builder.py:124-126)must_not: {is_disturbing: true}when enabled (elastic_query_builder.py:156-157)2. Saved-search injection — loop at
elastic_recommender.py:192-210callsarticle_and_video_search_for_user→build_elastic_search_query(elastic_query_builder.py:297). That builder filters only by search term + language + CEFR + recency. It has no avoid-keywordmust_notand no disturbingmust_not.Notably,
article_and_video_search_for_user(elastic_recommender.py:318-326) already computesunwanted_user_searchesandfilter_disturbingvia_prepare_user_constraints— then never passes them to the query builder. They're silently dropped.Because injected search articles are recent, they sort to the top by
published_timeand the client features one as the top card.Other paths with the same gap (for consideration)
topic_filter_for_user(elastic_recommender.py:364) — single-topic feed, no avoid-keyword exclusion./searchand/latest_searchendpoints — usebuild_elastic_search_query, so no avoid filtering (arguably fine for an explicit one-off search, less fine for a persisted subscription)./user_articles/recommended(when ES is down) — plain SQL, no avoid filtering.Open design question (why we're filing rather than just patching)
What should win when a user's avoid-list and their search-subscription conflict? Options to weigh:
unwanted_user_searches+filter_disturbingmust_notto the search-injection query too. Simplest; matches the intent that "avoid" is a hard block. Downside: a subscription foriransilently drops overlapping items with no explanation.Leaning toward at least (1) for the disturbing filter (a safety setting shouldn't be defeated by a topic subscription), and probably for keywords too — but worth a discussion before implementing.
Suggested fix (if we go with option 1)
Thread
unwanted_user_searchesandfilter_disturbingintobuild_elastic_search_queryand add the same twomust_notclauses the organic recommender already uses.