Add an AI slop verdict that downrates the author's future articles

A fourth explicit verdict, "AI slop" (Vote::Slop, label `slop`), joins the
rating widget on the web, the EPUB footer links, the /r/ confirmation page,
the CLI, imports and the dashboard filters. It counts as a full negative
(curation.feedback.slop_value, -1.0) in the neighbour and affinity signals.

Beyond that, each run loads the authors whose current verdict is slop, with
no lookback, and multiplies the preliminary blend and the utility of every
candidate by that author by 1 - curation.ranking.slop_author_penalty (0.75),
so they sink before triage. The flag is recorded in signals_json, shown by
`explain` and the dashboard signals table, and the confirmation names the
author (or says no author is known, in which case only the rating applies).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
This commit is contained in:
2026-09-09 03:36:50 +00:00
co-authored by Claude Fable 5.1
parent c6a9a98a0d
commit b02e1c7b8c
30 changed files with 485 additions and 84 deletions
+2 -2
View File
@@ -65,7 +65,7 @@ pub struct ArticlesQuery {
pub page: Option<u32>,
}
const RATED: [&str; 6] = ["any", "loved", "good", "down", "cleared", "none"];
const RATED: [&str; 7] = ["any", "loved", "good", "down", "slop", "cleared", "none"];
const PUBLISHED: [&str; 2] = ["yes", "no"];
const ARTICLE_SORTS: [(&str, &str); 7] = [
@@ -152,7 +152,7 @@ impl ArticleFilters {
match self.rated.as_deref() {
Some("any") => sql.push_str(" AND x.rating IS NOT NULL AND x.rating != 'cleared'"),
Some("none") => sql.push_str(" AND x.rating IS NULL"),
Some(label @ ("loved" | "good" | "cleared")) => {
Some(label @ ("loved" | "good" | "slop" | "cleared")) => {
sql.push_str(" AND x.rating = ?");
binds.push(Bind::Text(label.to_string()));
}
+3
View File
@@ -257,6 +257,7 @@ pub fn widget_label(label: Option<&str>) -> &'static str {
Some("not_for_me" | "down") => "down",
Some("loved") => "loved",
Some("good") => "good",
Some("slop") => "slop",
Some("cleared") => "cleared",
_ => "",
}
@@ -297,6 +298,7 @@ pub struct SignalsView {
pub neighbours: Vec<NeighbourLine>,
pub exploration: bool,
pub auto_include: bool,
pub slop_author: bool,
pub notes: Vec<String>,
/// A thin hygiene row (`{}`) or unparseable JSON: nothing to show.
pub empty: bool,
@@ -357,6 +359,7 @@ impl SignalsView {
.collect(),
exploration: signals.exploration,
auto_include: signals.auto_include,
slop_author: signals.slop_author,
notes: signals.notes.clone(),
empty,
}
+7 -1
View File
@@ -244,6 +244,7 @@ fn event_label(widget: &str) -> Option<&'static str> {
"loved" => Some("loved"),
"good" => Some("good"),
"down" => Some("not_for_me"),
"slop" => Some("slop"),
"cleared" => Some("cleared"),
_ => None,
}
@@ -255,6 +256,7 @@ fn widget_label(label: &str) -> (&'static str, &'static str) {
"loved" => ("loved", "Loved it"),
"good" => ("good", "Good"),
"not_for_me" | "down" => ("down", "Not for me"),
"slop" => ("slop", "AI slop"),
"cleared" => ("cleared", "Cleared"),
_ => ("", "Unknown"),
}
@@ -412,6 +414,8 @@ struct HowValues {
loved: String,
good: String,
not_for_me: String,
slop: String,
slop_author_penalty: String,
verdicts_in_prompt: usize,
rebuild_interval_days: i64,
max_ratings_in_rebuild: usize,
@@ -435,6 +439,8 @@ impl HowValues {
loved: format!("{:+.2}", feedback.loved_value),
good: format!("{:+.2}", feedback.good_value),
not_for_me: format!("{:+.2}", feedback.not_for_me_value),
slop: format!("{:+.2}", feedback.slop_value),
slop_author_penalty: format!("{:.0}%", ranking.slop_author_penalty * 100.0),
verdicts_in_prompt: feedback.verdicts_in_prompt,
rebuild_interval_days: REBUILD_INTERVAL_DAYS,
max_ratings_in_rebuild: MAX_RATINGS_IN_REBUILD,
@@ -681,7 +687,7 @@ async fn queue_import(
set_flash(&session, "error", message).await?;
return Ok(Redirect::to("/dashboard/ratings#imports").into_response());
}
if !matches!(label.as_str(), "loved" | "good" | "not_for_me") {
if !matches!(label.as_str(), "loved" | "good" | "not_for_me" | "slop") {
set_flash(&session, "error", "Choose a valid verdict.".into()).await?;
return Ok(Redirect::to("/dashboard/ratings#imports").into_response());
}
+2
View File
@@ -334,6 +334,7 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
("curation.feedback.loved_value", "Weight for a Loved it verdict."),
("curation.feedback.good_value", "Weight for a Good verdict."),
("curation.feedback.not_for_me_value", "Weight for a Not for me verdict."),
("curation.feedback.slop_value", "Weight for an AI slop verdict (the author penalty is separate)."),
("curation.feedback.verdicts_in_prompt", "Recent explicit verdicts included in the system prompt."),
("curation.ranking.triage_max", "Eligible articles the triage LLM reads."),
("curation.ranking.deep_keep", "Size of the deep-assessment set. Must be >= shortlist_keep."),
@@ -343,6 +344,7 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
("curation.ranking.rating_half_life_days", "Ratings decay with this half-life."),
("curation.ranking.neighbour_k", "Rated neighbours per side for the knn signal."),
("curation.ranking.negative_coefficient", "How strongly Not for me neighbours pull a candidate down."),
("curation.ranking.slop_author_penalty", "Fraction of the blend and utility removed from candidates whose author has a current AI slop verdict (0 disables, 1 zeroes)."),
("curation.ranking.knn_floor", "Rated articles with embeddings before the knn signal starts to count."),
("curation.ranking.knn_full", "Rated articles at which the knn signal reaches full weight. Must be > knn_floor."),
("curation.ranking.feed_floor", "Attributable ratings before the feed-affinity signal starts to count."),
+82
View File
@@ -2768,6 +2768,88 @@ mod tests {
assert_eq!(down, "not_for_me");
}
#[tokio::test]
async fn slop_verdict_is_stored_under_its_own_label_and_names_the_author() {
let (_dir, db, source) = seeded_issue(true).await;
let article_id = source.lineup.picks[0].article.id;
sqlx::query("UPDATE articles SET author = ? WHERE id = ?")
.bind("Content Farm")
.bind(article_id)
.execute(db.pool())
.await
.unwrap();
crate::web::users::add(&db, "admin", "correct horse battery", true)
.await
.unwrap();
let app = crate::server::router(crate::server::AppState::new(
db.clone(),
crate::config::Config::default(),
None,
));
let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await;
let json = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/rate")
.header(header::COOKIE, &admin_cookie)
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json")
.header("sec-fetch-site", "same-origin")
.body(Body::from(
json!({"article_id": article_id, "label": "slop"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(json.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(json).await).unwrap();
assert_eq!(body["label"], "slop");
let stored = sqlx::query("SELECT label, value FROM rating_events ORDER BY id DESC LIMIT 1")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(stored.get::<String, _>("label"), "slop");
assert_eq!(stored.get::<f64, _>("value"), -1.0);
assert_eq!(db.slop_authors().await.unwrap(), ["Content Farm"]);
let redirected = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/rate")
.header(header::COOKIE, &admin_cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("sec-fetch-site", "same-origin")
.body(Body::from(format!(
"article_id={article_id}&label=slop&next=%2Fissues%2F{}",
source.meta.date
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(redirected.status(), StatusCode::SEE_OTHER);
let page = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.header(header::COOKIE, &admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let html = response_text(page).await;
assert!(html.contains("Future articles by Content Farm will rank much lower"));
assert!(html.contains(r#"data-label="slop" title="Report as AI slop"#));
assert!(html.contains(r#"value="slop" data-label="slop" title="Report as AI slop: a strong Not for me, and future articles by this author rank much lower" class="active" aria-pressed="true""#));
}
#[tokio::test]
async fn toc_numbers_chapters_across_sections_and_tracks_the_reader() {
let (_dir, db, source) = seeded_issue(true).await;
+22 -23
View File
@@ -45,6 +45,7 @@ fn web_label(label: Option<&str>) -> &str {
Some("not_for_me" | "down") => "down",
Some("loved") => "loved",
Some("good") => "good",
Some("slop") => "slop",
Some("cleared") => "cleared",
_ => "",
}
@@ -122,12 +123,12 @@ pub async fn post(
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/rate".into(),
})?;
if state.db.get_article(input.article_id).await?.is_none() {
let Some(article) = state.db.get_article(input.article_id).await? else {
return Err(WebError::BadRequest(format!(
"article {} does not exist",
input.article_id
)));
}
};
let issue_date = match input
.issue_date
@@ -147,27 +148,24 @@ pub async fn post(
}
};
let config = state.config();
let (event_label, value, response_label, flash_label) = match input.label.as_str() {
"loved" => (
"loved",
Vote::Loved.value(&config.curation.feedback),
"loved",
"Loved it",
let (event_label, value, response_label, flash_text) = match input.label.as_str() {
"cleared" => ("cleared", 0.0, "cleared", "Rated: Cleared".to_string()),
"slop" => (
"slop",
Vote::Slop.value(&config.curation.feedback),
"slop",
crate::rate::slop_message(article.author.as_deref()),
),
"good" => (
"good",
Vote::Good.value(&config.curation.feedback),
"good",
"Good",
),
"down" => (
"not_for_me",
Vote::NotForMe.value(&config.curation.feedback),
"down",
"Not for me",
),
"cleared" => ("cleared", 0.0, "cleared", "Cleared"),
_ => return Err(WebError::BadRequest("invalid rating label".into())),
widget => {
let vote = Vote::parse(widget)
.ok_or_else(|| WebError::BadRequest("invalid rating label".into()))?;
(
vote.event_label(),
vote.value(&config.curation.feedback),
vote.as_str(),
format!("Rated: {}", vote.display()),
)
}
};
let note = input
.note
@@ -207,7 +205,7 @@ pub async fn post(
"flash",
Flash {
kind: "success".into(),
text: format!("Rated: {flash_label}"),
text: flash_text,
},
)
.await
@@ -223,6 +221,7 @@ mod tests {
#[test]
fn database_and_widget_labels_are_mapped_explicitly() {
assert_eq!(web_label(Some("not_for_me")), "down");
assert_eq!(web_label(Some("slop")), "slop");
assert_eq!(web_label(Some("cleared")), "cleared");
assert_eq!(web_label(None), "");
}
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -151,7 +151,7 @@
.badge { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5 tabular-nums; color:var(--ink-2); background:color-mix(in oklab, var(--ink) 8%, transparent); }
.badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
.badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
.badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
.badge.down, .badge.slop, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
.badge.shortlisted, .badge.admitted, .badge.eligible, .badge.cleared, .badge.reason, .badge.running, .badge.requested { color:var(--muted); background:color-mix(in oklab, var(--muted) 14%, transparent); }
.badge.restart, .badge.warn { color:var(--warn); background:color-mix(in oklab, var(--warn) 16%, transparent); }
@@ -196,7 +196,7 @@
.rating button[data-label]:not(.clear) { @apply -ml-px rounded-none border-rule bg-transparent px-2 py-1 text-ink-2 first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2 hover:text-ink; }
.rating button.active[data-label="loved"] { @apply z-10 border-loved text-loved; background:color-mix(in oklab, var(--loved) 14%, transparent); }
.rating button.active[data-label="good"] { @apply z-10 border-good text-good; background:color-mix(in oklab, var(--good) 14%, transparent); }
.rating button.active[data-label="down"] { @apply z-10 border-down text-down; background:color-mix(in oklab, var(--down) 14%, transparent); }
.rating button.active[data-label="down"], .rating button.active[data-label="slop"] { @apply z-10 border-down text-down; background:color-mix(in oklab, var(--down) 14%, transparent); }
.rating button.clear { @apply invisible ml-1.5 border-0 bg-transparent px-1 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
.rating:has(button[data-label]:not(.clear).active) button.clear, .rating button.clear.active { @apply visible; }
.rating button.clear.active { @apply no-underline; }
@@ -288,7 +288,7 @@
td .rating-note-label { @apply sr-only; }
td .rating-note input { @apply text-xs; }
/* the ratings table's verdict cell: one column, buttons then note, nothing wraps */
.rating-cell .rating { @apply grid w-[15rem] grid-cols-1 gap-y-1.5; }
.rating-cell .rating { @apply grid w-[18rem] grid-cols-1 gap-y-1.5; }
.rating-cell .rating-actions { @apply flex w-full min-h-0 items-center; }
.rating-cell .rating button.clear { @apply ml-auto; }
+1
View File
@@ -7,6 +7,7 @@
<button type="submit" name="label" value="loved" data-label="loved"{% if widget.current == "loved" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Loved it</button>
<button type="submit" name="label" value="good" data-label="good"{% if widget.current == "good" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Good</button>
<button type="submit" name="label" value="down" data-label="down"{% if widget.current == "down" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Not for me</button>
<button type="submit" name="label" value="slop" data-label="slop" title="Report as AI slop: a strong Not for me, and future articles by this author rank much lower"{% if widget.current == "slop" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>AI slop</button>
<button class="clear{% if widget.current == "cleared" %} active{% endif %}" type="submit" name="label" value="cleared" data-label="cleared"{% if widget.current == "cleared" %} aria-pressed="true"{% else %} aria-pressed="false"{% endif %}><span class="rating-clear-label">clear</span><span class="rating-cleared-label">cleared</span></button>
</span>
{% if widget.show_note %}<label class="rating-note"><span class="rating-note-label">Note</span><input name="note" placeholder="Add a note…"></label>{% endif %}
+1 -1
View File
@@ -1,7 +1,7 @@
{% if signals.empty %}<p class="muted text-xs">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}{% if signals.slop_author %} · <span class="badge down">slop author</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p class="page-eyebrow">Nearest rated neighbours</p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
+6 -5
View File
@@ -9,7 +9,7 @@
<div class="disclosure-body">
<form method="post" action="/dashboard/ratings/import" class="filters">
<label class="w-full">URLs <textarea name="urls" rows="5" required class="w-full" placeholder="One URL per line (commas and spaces also work)" spellcheck="false"></textarea></label>
<label>Verdict <select name="label"><option value="loved" selected>Loved it</option><option value="good">Good</option><option value="not_for_me">Not for me</option></select></label>
<label>Verdict <select name="label"><option value="loved" selected>Loved it</option><option value="good">Good</option><option value="not_for_me">Not for me</option><option value="slop">AI slop</option></select></label>
<label>Note <input name="note" type="text" placeholder="Optional note"></label>
<div class="filter-actions"><button class="btn btn-primary" type="submit">Queue import</button></div>
</form>
@@ -34,10 +34,11 @@
<ol class="list-decimal space-y-3 pl-5 marker:text-muted">
<li><strong>Prompt verdict block.</strong> The {{ how.verdicts_in_prompt }} most recent non-cleared verdicts, newest first, are written into every LLM call's system prompt as one line each (<code>LOVED | title | feed | summary</code>). Only the rank matters here — a verdict never ages out of this block, it is pushed out by newer ones. Tune <a href="/dashboard/settings#curation.feedback"><code>curation.feedback.verdicts_in_prompt</code></a>.</li>
<li><strong>Weekly learned adjustments.</strong> Every {{ how.rebuild_interval_days }} days the editor model rewrites the profile's "Learned adjustments" bullets from the {{ how.max_ratings_in_rebuild }} most recent non-cleared verdicts, including notes and deep-assessment facets. See the <a href="/dashboard/profile">Profile</a> page.</li>
<li><strong>Rated-neighbour signal.</strong> Each verdict with an embedding is an example with weight <code>value × 0.5^(age / {{ how.half_life_days }} days)</code>, where loved = {{ how.loved }}, good = {{ how.good }}, not for me = {{ how.not_for_me }}; ratings older than {{ how.lookback_days }} days are not loaded. A candidate's signal is the weighted mean cosine to its {{ how.neighbour_k }} nearest positive examples minus {{ how.negative_coefficient }} × the same over its nearest negative ones. The signal's preliminary weight ({{ how.knn_weight }}) is scaled by a gate that opens above {{ how.knn_floor }} embedded verdicts and is fully open at {{ how.knn_full }}. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.rating_half_life_days</code>, <code>knn_floor</code>, <code>knn_full</code>, <code>neighbour_k</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.knn</code></a>.</li>
<li><strong>Rated-neighbour signal.</strong> Each verdict with an embedding is an example with weight <code>value × 0.5^(age / {{ how.half_life_days }} days)</code>, where loved = {{ how.loved }}, good = {{ how.good }}, not for me = {{ how.not_for_me }}, AI slop = {{ how.slop }}; ratings older than {{ how.lookback_days }} days are not loaded. A candidate's signal is the weighted mean cosine to its {{ how.neighbour_k }} nearest positive examples minus {{ how.negative_coefficient }} × the same over its nearest negative ones. The signal's preliminary weight ({{ how.knn_weight }}) is scaled by a gate that opens above {{ how.knn_floor }} embedded verdicts and is fully open at {{ how.knn_full }}. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.rating_half_life_days</code>, <code>knn_floor</code>, <code>knn_full</code>, <code>neighbour_k</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.knn</code></a>.</li>
<li><strong>Feed affinity.</strong> The same decayed weight is credited to the rated article's direct feeds, split evenly; each feed's Beta-smoothed rate <code>(up + 1) / (up + down + 2)</code> becomes a candidate's signal (the mean over its rated direct feeds). Its weight ({{ how.feed_weight }}) is gated between {{ how.feed_floor }} and {{ how.feed_full }} attributable ratings. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.feed_floor</code>, <code>feed_full</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.feed</code></a>.</li>
<li><strong>Slop authors.</strong> An <em>AI slop</em> verdict is also a report against the article's author: while it is the article's current verdict, every candidate by that author (same normalized name, any feed, no age limit) has its preliminary blend and utility cut by {{ how.slop_author_penalty }}. Articles without a known author get only the ordinary negative rating. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.slop_author_penalty</code></a>.</li>
</ol>
<p class="muted">Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
<p class="muted">Clearing a verdict removes it from all five paths without deleting history; ratings are append-only.</p>
</div>
</details>
@@ -46,7 +47,7 @@
{% if tab == "events" %}
<form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="events">
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="slop"{% if filter_label == "slop" %} selected{% endif %}>AI slop</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
<label>User <select name="user"><option value="">any</option>{% for username in usernames %}<option value="{{ username }}"{% if filter_user == username.as_str() %} selected{% endif %}>{{ username }}</option>{% endfor %}</select></label>
<label>From <input type="date" name="from" value="{{ filter_from }}"></label>
@@ -75,7 +76,7 @@
{% else %}
<form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="current">
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="slop"{% if filter_label == "slop" %} selected{% endif %}>AI slop</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
<label>Feed <select name="feed"><option value="">any</option>{% for feed in feeds %}<option value="{{ feed.id }}"{% if filter_feed == feed.id.to_string() %} selected{% endif %}>{{ feed.title }}</option>{% endfor %}</select></label>
<label>Title <input type="search" name="q" value="{{ filter_q }}" placeholder="contains…"></label>