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
+9 -4
View File
@@ -15,7 +15,8 @@ publishes the lot over its own OPDS catalog — which doubles as a
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one. [BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
Each article chapter ends with a one-line note of what the pipeline made of the Each article chapter ends with a one-line note of what the pipeline made of the
piece (topic, format, depth, and the reader interests it matched) and Loved it / piece (topic, format, depth, and the reader interests it matched) and Loved it /
Good / Not for me links that feed back into tomorrow's curation; a short *Behind Good / Not for me / AI slop links that feed back into tomorrow's curation (an AI
slop report also cuts every future article by that author); a short *Behind
the paper* chapter before the colophon says what the run considered, how the the paper* chapter before the colophon says what the run considered, how the
deep set was admitted, whether the learned signals were active, the ten deep set was admitted, whether the learned signals were active, the ten
highest-utility near misses, and what it all cost. highest-utility near misses, and what it all cost.
@@ -125,7 +126,7 @@ is current.
daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore] daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore]
daily-epub serve # public site, private dashboard, OPDS, ratings, downloads daily-epub serve # public site, private dashboard, OPDS, ratings, downloads
daily-epub profile rebuild # regenerate learned profile adjustments daily-epub profile rebuild # regenerate learned profile adjustments
daily-epub ratings list [--days 90] [--label loved|good|down|cleared] daily-epub ratings list [--days 90] [--label loved|good|down|slop|cleared]
daily-epub ratings set --article 42 --label loved --note "excellent" daily-epub ratings set --article 42 --label loved --note "excellent"
daily-epub ratings clear --url https://example.com/article daily-epub ratings clear --url https://example.com/article
daily-epub explain --date YYYY-MM-DD (--article ID | --url URL) [--run-id N] daily-epub explain --date YYYY-MM-DD (--article ID | --url URL) [--run-id N]
@@ -403,6 +404,7 @@ prints what resolved.
| `curation.feedback.loved_value` | `1.0` | Weight for a Loved it verdict. | | `curation.feedback.loved_value` | `1.0` | Weight for a Loved it verdict. |
| `curation.feedback.good_value` | `0.35` | Weight for a Good verdict. | | `curation.feedback.good_value` | `0.35` | Weight for a Good verdict. |
| `curation.feedback.not_for_me_value` | `-1.0` | Weight for a Not for me verdict. | | `curation.feedback.not_for_me_value` | `-1.0` | Weight for a Not for me verdict. |
| `curation.feedback.slop_value` | `-1.0` | Weight for an AI slop verdict; the author penalty is `curation.ranking.slop_author_penalty`. |
| `curation.feedback.verdicts_in_prompt` | `60` | Recent explicit verdicts included in the system prompt. | | `curation.feedback.verdicts_in_prompt` | `60` | Recent explicit verdicts included in the system prompt. |
| `curation.recent_rejection_days` | `7` | Churn window for recent low triage/deep assessments. | | `curation.recent_rejection_days` | `7` | Churn window for recent low triage/deep assessments. |
| `curation.recent_rejection_floor` | `3.0` | Scores below this floor are excluded during the churn window (except auto-includes). | | `curation.recent_rejection_floor` | `3.0` | Scores below this floor are excluded during the churn window (except auto-includes). |
@@ -448,7 +450,10 @@ gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to
`feed_floor` (15) to `feed_full` (40) attributable ratings; below the floor the `feed_floor` (15) to `feed_full` (40) attributable ratings; below the floor the
signal is absent. Ratings decay with `rating_half_life_days` (60) over signal is absent. Ratings decay with `rating_half_life_days` (60) over
`rating_lookback_days` (180); `neighbour_k` (5) neighbours per side and `rating_lookback_days` (180); `neighbour_k` (5) neighbours per side and
`negative_coefficient` (0.75) shape the signal. `triage_max` (800), `negative_coefficient` (0.75) shape the signal. `slop_author_penalty` (0.75)
is the fraction of the blend and utility removed from every candidate whose
author currently carries an AI slop verdict, on any feed and with no age
limit. `triage_max` (800),
`deep_keep` (120), `shortlist_keep` (60), `assessment_reuse_days` (3), `deep_keep` (120), `shortlist_keep` (60), `assessment_reuse_days` (3),
`semantic_min_words` (300), `exploration_slots` (5), `[curation.ranking.quotas]` `semantic_min_words` (300), `exploration_slots` (5), `[curation.ranking.quotas]`
(`triage` 60 · `interest` 20 · `knn` 20), `[curation.ranking.weights.utility]` (`triage` 60 · `interest` 20 · `knn` 20), `[curation.ranking.weights.utility]`
@@ -854,7 +859,7 @@ curl -s https://daily.hallada.net/healthz
curl -s https://daily.hallada.net/opds/daily.xml | head curl -s https://daily.hallada.net/opds/daily.xml | head
curl -s https://daily.hallada.net/issues.json | jq '.[0]' curl -s https://daily.hallada.net/issues.json | jq '.[0]'
# 7. Feedback loop: tap Loved it / Good / Not for me in KOReader, then # 7. Feedback loop: tap Loved it / Good / Not for me / AI slop in KOReader, then
sqlite3 /var/lib/daily-epub/daily-epub.db 'select * from rating_events order by event_at desc;' sqlite3 /var/lib/daily-epub/daily-epub.db 'select * from rating_events order by event_at desc;'
# 8. Watch cost and quality for a week # 8. Watch cost and quality for a week
+2
View File
@@ -132,6 +132,7 @@ sections = [
loved_value = 1.0 loved_value = 1.0
good_value = 0.35 good_value = 0.35
not_for_me_value = -1.0 not_for_me_value = -1.0
slop_value = -1.0 # AI slop: a full negative; the author penalty is below
verdicts_in_prompt = 60 verdicts_in_prompt = 60
# Every weight, quota, gate and threshold of the personalized ranker. The # Every weight, quota, gate and threshold of the personalized ranker. The
@@ -150,6 +151,7 @@ knn_floor = 8
knn_full = 25 knn_full = 25
feed_floor = 15 feed_floor = 15
feed_full = 40 feed_full = 40
slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict
semantic_min_words = 300 semantic_min_words = 300
exploration_slots = 5 exploration_slots = 5
embedding_retention_days = 120 # `features prune`: unrated, unpublished vectors embedding_retention_days = 120 # `features prune`: unrated, unpublished vectors
+16
View File
@@ -468,6 +468,10 @@ pub struct RankingConfig {
pub knn_full: usize, pub knn_full: usize,
pub feed_floor: usize, pub feed_floor: usize,
pub feed_full: usize, pub feed_full: usize,
/// Fraction of the preliminary blend and the utility removed from any
/// candidate whose author has a current *AI slop* verdict (§9.3). `1.0`
/// zeroes such candidates; `0.0` disables the penalty.
pub slop_author_penalty: f64,
pub semantic_min_words: i64, pub semantic_min_words: i64,
pub exploration_slots: usize, pub exploration_slots: usize,
pub embedding_retention_days: i64, pub embedding_retention_days: i64,
@@ -492,6 +496,7 @@ impl Default for RankingConfig {
knn_full: 25, knn_full: 25,
feed_floor: 15, feed_floor: 15,
feed_full: 40, feed_full: 40,
slop_author_penalty: 0.75,
semantic_min_words: 300, semantic_min_words: 300,
exploration_slots: 5, exploration_slots: 5,
embedding_retention_days: 120, embedding_retention_days: 120,
@@ -603,6 +608,9 @@ pub struct FeedbackConfig {
pub loved_value: f64, pub loved_value: f64,
pub good_value: f64, pub good_value: f64,
pub not_for_me_value: f64, pub not_for_me_value: f64,
/// Weight of an *AI slop* verdict. The author penalty is separate
/// (`ranking.slop_author_penalty`).
pub slop_value: f64,
pub verdicts_in_prompt: usize, pub verdicts_in_prompt: usize,
} }
@@ -612,6 +620,7 @@ impl Default for FeedbackConfig {
loved_value: 1.0, loved_value: 1.0,
good_value: 0.35, good_value: 0.35,
not_for_me_value: -1.0, not_for_me_value: -1.0,
slop_value: -1.0,
verdicts_in_prompt: 60, verdicts_in_prompt: 60,
} }
} }
@@ -1180,6 +1189,11 @@ impl Config {
"curation.ranking *_full must be > *_floor >= 0".into(), "curation.ranking *_full must be > *_floor >= 0".into(),
)); ));
} }
if !(0.0..=1.0).contains(&ranking.slop_author_penalty) {
return Err(ConfigError::Invalid(
"curation.ranking.slop_author_penalty must be between 0 and 1".into(),
));
}
if !(0.0..=1.0).contains(&ranking.diversity.cluster_threshold) { if !(0.0..=1.0).contains(&ranking.diversity.cluster_threshold) {
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
"curation.ranking.diversity.cluster_threshold must be between 0 and 1".into(), "curation.ranking.diversity.cluster_threshold must be between 0 and 1".into(),
@@ -1438,6 +1452,8 @@ mod tests {
assert_eq!(c.curation.recent_rejection_floor, 3.0); assert_eq!(c.curation.recent_rejection_floor, 3.0);
assert_eq!(c.profile_path, PathBuf::from("data/profile.md")); assert_eq!(c.profile_path, PathBuf::from("data/profile.md"));
assert_eq!(c.curation.feedback.good_value, 0.35); assert_eq!(c.curation.feedback.good_value, 0.35);
assert_eq!(c.curation.feedback.slop_value, -1.0);
assert_eq!(c.curation.ranking.slop_author_penalty, 0.75);
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60); assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
assert_eq!(c.xtc.format, XtcFormat::Xtch); assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.curation.sections.len(), 8); assert_eq!(c.curation.sections.len(), 8);
+1
View File
@@ -211,6 +211,7 @@ fn render_candidate(candidate: &Candidate) -> String {
"loved" => "LOVED", "loved" => "LOVED",
"good" => "GOOD", "good" => "GOOD",
"not_for_me" | "down" => "NOT FOR ME", "not_for_me" | "down" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other, other => other,
}; };
format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos) format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos)
+1
View File
@@ -172,6 +172,7 @@ fn render_candidate(candidate: &Candidate) -> String {
"loved" => "LOVED", "loved" => "LOVED",
"good" => "GOOD", "good" => "GOOD",
"not_for_me" | "down" => "NOT FOR ME", "not_for_me" | "down" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other, other => other,
}; };
format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos) format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos)
+1
View File
@@ -162,6 +162,7 @@ fn verdict_label(label: &str) -> &str {
"loved" => "LOVED", "loved" => "LOVED",
"good" => "GOOD", "good" => "GOOD",
"not_for_me" => "NOT FOR ME", "not_for_me" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other, other => other,
} }
} }
+21 -2
View File
@@ -13,7 +13,8 @@ pub struct RankSummary {
pub clusters: usize, pub clusters: usize,
} }
/// Re-normalize cheap signals over the deep set and calculate utility on 0–100. /// Re-normalize cheap signals over the deep set and calculate utility on 0–100,
/// scaled by the slop-author factor (§9.3).
pub fn calculate_utility(candidates: &mut [Candidate], configured: &UtilityWeights) { pub fn calculate_utility(candidates: &mut [Candidate], configured: &UtilityWeights) {
let indices = (0..candidates.len()).collect::<Vec<_>>(); let indices = (0..candidates.len()).collect::<Vec<_>>();
calculate_utility_for(candidates, &indices, configured); calculate_utility_for(candidates, &indices, configured);
@@ -88,7 +89,8 @@ fn calculate_utility_for(
.iter() .iter()
.map(|(_, weight, value)| weight / total * value) .map(|(_, weight, value)| weight / total * value)
.sum::<f64>() .sum::<f64>()
* 100.0, * 100.0
* candidate.signals.slop_factor(),
); );
} }
} }
@@ -345,6 +347,23 @@ mod tests {
); );
} }
#[test]
fn slop_authors_keep_their_weights_but_lose_most_of_their_utility() {
let mut reported = candidate(1, 8.0);
let mut other = candidate(2, 8.0);
reported.signals.slop_author = true;
reported.signals.slop_penalty = 0.75;
other.signals.slop_author = false;
other.signals.slop_penalty = 0.75;
let mut values = vec![reported, other];
calculate_utility(&mut values, &UtilityWeights::default());
let [reported, other] = values.as_slice() else {
panic!("two values")
};
assert_eq!(reported.signals.weights, other.signals.weights);
assert!((reported.utility.unwrap() - other.utility.unwrap() * 0.25).abs() < 1e-9);
}
#[test] #[test]
fn duplicates_cluster_and_the_third_is_suppressed() { fn duplicates_cluster_and_the_third_is_suppressed() {
let ranking = ranking(3, 0, 2, 0.85); let ranking = ranking(3, 0, 2, 0.85);
+123 -5
View File
@@ -72,11 +72,29 @@ pub struct Signals {
pub notes: Vec<String>, pub notes: Vec<String>,
/// Preliminary blend on a 0–100 scale; `None` when nothing is present. /// Preliminary blend on a 0–100 scale; `None` when nothing is present.
pub preliminary: Option<f64>, pub preliminary: Option<f64>,
/// The author has a current *AI slop* verdict (§9.3).
#[serde(default)]
pub slop_author: bool,
/// Gate ramps applied to the learned signals' weights (§9.2, §9.3). /// Gate ramps applied to the learned signals' weights (§9.2, §9.3).
#[serde(skip)] #[serde(skip)]
pub knn_gate: f64, pub knn_gate: f64,
#[serde(skip)] #[serde(skip)]
pub feed_gate: f64, pub feed_gate: f64,
/// `ranking.slop_author_penalty`, applied when `slop_author` is set.
#[serde(skip)]
pub slop_penalty: f64,
}
impl Signals {
/// The multiplier the slop-author penalty applies to the blend and the
/// utility: `1 − penalty` for a reported author, `1` otherwise (§9.3).
pub fn slop_factor(&self) -> f64 {
if self.slop_author {
(1.0 - self.slop_penalty).clamp(0.0, 1.0)
} else {
1.0
}
}
} }
impl Signals { impl Signals {
@@ -161,6 +179,9 @@ pub struct PreferenceState {
pub examples: Vec<RatedExample>, pub examples: Vec<RatedExample>,
feed_rates: HashMap<FeedId, FeedRate>, feed_rates: HashMap<FeedId, FeedRate>,
author_rates: HashMap<String, FeedRate>, author_rates: HashMap<String, FeedRate>,
/// Normalized keys of authors with a current *AI slop* verdict (§9.3).
slop_authors: HashSet<String>,
pub slop_author_penalty: f64,
pub attributable_feed_ratings: usize, pub attributable_feed_ratings: usize,
pub knn_gate: f64, pub knn_gate: f64,
pub feed_gate: f64, pub feed_gate: f64,
@@ -183,10 +204,36 @@ impl PreferenceState {
examples, examples,
feed_rates, feed_rates,
author_rates, author_rates,
slop_authors: HashSet::new(),
slop_author_penalty: ranking.slop_author_penalty,
attributable_feed_ratings, attributable_feed_ratings,
} }
} }
/// Register the authors whose current verdict is *AI slop*; keys are
/// normalized like [`normalize_author`] and empty ones are dropped.
pub fn with_slop_authors<I, S>(mut self, authors: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.slop_authors = authors
.into_iter()
.filter_map(|author| normalize_author(Some(author.as_ref())))
.collect();
self
}
/// Whether the article's author has a current *AI slop* verdict (§9.3).
pub fn is_slop_author(&self, article: &Article) -> bool {
normalize_author(article.author.as_deref())
.is_some_and(|author| self.slop_authors.contains(&author))
}
pub fn slop_author_count(&self) -> usize {
self.slop_authors.len()
}
/// Load `db::current_ratings(rating_lookback_days)` joined to /// Load `db::current_ratings(rating_lookback_days)` joined to
/// `article_embeddings`; ratings without an embedding are skipped (§9.2). /// `article_embeddings`; ratings without an embedding are skipped (§9.2).
pub async fn load( pub async fn load(
@@ -227,7 +274,8 @@ impl PreferenceState {
aggregator_only, aggregator_only,
}); });
} }
Ok(Self::build(examples, ranking)) let slop_authors = db.slop_authors().await?;
Ok(Self::build(examples, ranking).with_slop_authors(slop_authors))
} }
pub fn summary(&self) -> PreferenceSummary { pub fn summary(&self) -> PreferenceSummary {
@@ -253,11 +301,14 @@ impl PreferenceState {
rated_with_embeddings = self.examples.len(), rated_with_embeddings = self.examples.len(),
knn_gate = self.knn_gate, knn_gate = self.knn_gate,
feed_gate = self.feed_gate, feed_gate = self.feed_gate,
"preference: {} rated articles with embeddings → knn gate {:.2}; feed gate {:.1} {}", slop_authors = self.slop_authors.len(),
"preference: {} rated articles with embeddings → knn gate {:.2}; feed gate {:.1} {}; {} slop authors (penalty {:.2})",
self.examples.len(), self.examples.len(),
self.knn_gate, self.knn_gate,
self.feed_gate, self.feed_gate,
feed_detail feed_detail,
self.slop_authors.len(),
self.slop_author_penalty
); );
} }
@@ -540,6 +591,14 @@ pub fn compute(
signals.neighbours = neighbours; signals.neighbours = neighbours;
} }
signals.feed = preference.feed(article); signals.feed = preference.feed(article);
if preference.is_slop_author(article) {
signals.slop_author = true;
signals.slop_penalty = preference.slop_author_penalty;
signals.notes.push(format!(
"author reported as AI slop: blend and utility × {:.2}",
signals.slop_factor()
));
}
if preference.knn_gate > 0.0 { if preference.knn_gate > 0.0 {
signals.notes.push(format!( signals.notes.push(format!(
"knn gate {:.2} (n={} rated with embeddings)", "knn gate {:.2} (n={} rated with embeddings)",
@@ -615,7 +674,8 @@ pub fn normalize(signals: &mut [&mut Signals]) {
} }
/// The preliminary blend of §12.4 on a 0–100 scale: present-and-active /// The preliminary blend of §12.4 on a 0–100 scale: present-and-active
/// signals only, learned weights multiplied by their gate, renormalized to 1. /// signals only, learned weights multiplied by their gate, renormalized to 1,
/// then scaled by the slop-author factor (§9.3).
pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights) -> Option<f64> { pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights) -> Option<f64> {
let candidates = [ let candidates = [
("interest", configured.interest, 1.0), ("interest", configured.interest, 1.0),
@@ -646,7 +706,8 @@ pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights)
.iter() .iter()
.map(|(_, weight, norm)| weight / total * norm) .map(|(_, weight, norm)| weight / total * norm)
.sum::<f64>() .sum::<f64>()
* 100.0; * 100.0
* signals.slop_factor();
signals.preliminary = Some(blend); signals.preliminary = Some(blend);
Some(blend) Some(blend)
} }
@@ -964,6 +1025,63 @@ mod tests {
assert!((state.feed(&article(2, &[10])).unwrap() - 1.0 / 2.25).abs() < 1e-9); assert!((state.feed(&article(2, &[10])).unwrap() - 1.0 / 2.25).abs() < 1e-9);
} }
// --- §9.3 slop authors ---
#[test]
fn slop_authors_are_normalized_and_scale_the_preliminary_blend() {
let state = PreferenceState::build(Vec::new(), &ranking()).with_slop_authors([
" Content FARM ",
"",
" ",
]);
assert_eq!(state.slop_author_count(), 1);
let mut reported = article(1, &[10]);
reported.author = Some("content farm".into());
let mut other = article(2, &[10]);
other.author = Some("real writer".into());
let anonymous = article(3, &[10]);
assert!(state.is_slop_author(&reported));
assert!(!state.is_slop_author(&other));
assert!(!state.is_slop_author(&anonymous));
let articles = vec![reported, other, anonymous];
let computed = compute(
&articles,
&HashMap::new(),
&HashMap::new(),
&state,
&ranking(),
);
let reported = &computed[&1];
let other = &computed[&2];
assert!(reported.slop_author);
assert!(!other.slop_author);
// Identical heuristic/social inputs: the only difference is the factor.
assert!((reported.preliminary.unwrap() - other.preliminary.unwrap() * 0.25).abs() < 1e-9);
assert!(
reported
.notes
.iter()
.any(|note| note.contains("author reported as AI slop"))
);
assert!(!other.notes.iter().any(|note| note.contains("AI slop")));
}
#[test]
fn slop_factor_is_neutral_without_a_report_and_clamped_with_one() {
let mut signals = Signals {
slop_penalty: 0.75,
..Signals::default()
};
assert_eq!(signals.slop_factor(), 1.0);
signals.slop_author = true;
assert!((signals.slop_factor() - 0.25).abs() < 1e-9);
signals.slop_penalty = 1.0;
assert_eq!(signals.slop_factor(), 0.0);
signals.slop_penalty = 0.0;
assert_eq!(signals.slop_factor(), 1.0);
}
#[test] #[test]
fn feed_is_absent_when_the_gate_is_closed() { fn feed_is_absent_when_the_gate_is_closed() {
let ranking = ranking(); // feed_floor 15 let ranking = ranking(); // feed_floor 15
+7 -3
View File
@@ -129,6 +129,9 @@ pub struct SignalsJson {
pub exploration: bool, pub exploration: bool,
#[serde(default)] #[serde(default)]
pub auto_include: bool, pub auto_include: bool,
/// The author had a current *AI slop* verdict at run time (§9.3).
#[serde(default)]
pub slop_author: bool,
#[serde(default)] #[serde(default)]
pub notes: Vec<String>, pub notes: Vec<String>,
} }
@@ -177,6 +180,7 @@ pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String {
neighbours: signals.neighbours.clone(), neighbours: signals.neighbours.clone(),
exploration: false, exploration: false,
auto_include, auto_include,
slop_author: signals.slop_author,
notes: signals.notes.clone(), notes: signals.notes.clone(),
}) })
.unwrap_or_else(|_| "{}".into()) .unwrap_or_else(|_| "{}".into())
@@ -400,11 +404,11 @@ pub async fn render_explain(db: &Db, row: &ExplainRow) -> Result<String, sqlx::E
); );
} }
} }
if signals.exploration || signals.auto_include { if signals.exploration || signals.auto_include || signals.slop_author {
let _ = writeln!( let _ = writeln!(
out, out,
"flags: exploration={} auto_include={}", "flags: exploration={} auto_include={} slop_author={}",
signals.exploration, signals.auto_include signals.exploration, signals.auto_include, signals.slop_author
); );
} }
for note in &signals.notes { for note in &signals.notes {
+1
View File
@@ -154,6 +154,7 @@ fn render_candidate(candidate: &Candidate) -> String {
"loved" => "LOVED", "loved" => "LOVED",
"good" => "GOOD", "good" => "GOOD",
"not_for_me" | "down" => "NOT FOR ME", "not_for_me" | "down" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other, other => other,
}; };
format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos) format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos)
+88
View File
@@ -719,6 +719,33 @@ impl Db {
self.latest_explicit_ratings(lookback_days, true).await self.latest_explicit_ratings(lookback_days, true).await
} }
/// Raw authors of every article whose current explicit verdict is `slop`,
/// regardless of age: a reported author stays penalized until the verdict is
/// changed or cleared (§9.3). Articles without an author contribute nothing.
pub async fn slop_authors(&self) -> Result<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"WITH ranked AS (
SELECT re.article_id, re.label,
ROW_NUMBER() OVER (
PARTITION BY re.article_id
ORDER BY re.event_at DESC, re.id DESC
) AS event_rank
FROM rating_events re
WHERE re.kind = 'explicit'
)
SELECT DISTINCT COALESCE(a.author, e.author) AS author
FROM ranked r
JOIN articles a ON a.id = r.article_id
LEFT JOIN entries e ON e.id = a.best_entry_id
WHERE r.event_rank = 1 AND r.label = 'slop'
AND COALESCE(a.author, e.author) IS NOT NULL
ORDER BY author",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn latest_explicit_ratings( async fn latest_explicit_ratings(
&self, &self,
lookback_days: i64, lookback_days: i64,
@@ -1551,6 +1578,67 @@ mod tests {
assert_eq!(sources, ["cli", "cli", "bookorbit", "cli"]); assert_eq!(sources, ["cli", "cli", "bookorbit", "cli"]);
} }
#[tokio::test]
async fn slop_authors_follow_the_latest_verdict_and_fall_back_to_the_entry_author() {
let (_dir, db) = temp_db().await;
db.upsert_entry(&sample_entry(1)).await.unwrap();
db.upsert_entry(&sample_entry(2)).await.unwrap();
let mut page_author = Article {
id: 0,
canonical_url: "https://example.com/1".into(),
title: "Story 1".into(),
best_entry_id: 1,
content_html: "<p>body</p>".into(),
word_count: 900,
excerpt_only: false,
image_count: 0,
sources: vec![],
first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(),
author: Some("Page Writer".into()),
feed_id: 7,
feed_title: "Hacker News".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: vec![],
social: vec![],
extract_method: ExtractMethod::Miniflux,
};
let first = db.upsert_article(&page_author).await.unwrap();
page_author.canonical_url = "https://example.com/2".into();
page_author.url = "https://example.com/2".into();
page_author.best_entry_id = 2;
page_author.author = None;
let second = db.upsert_article(&page_author).await.unwrap();
let event = |article_id: i64, label: &str, at: &str| RatingEvent {
id: 0,
user_id: None,
article_id,
issue_date: None,
kind: "explicit".into(),
source: "cli".into(),
label: label.into(),
value: -1.0,
note: None,
event_at: ts(at),
};
assert!(db.slop_authors().await.unwrap().is_empty());
// Ancient verdicts still count: there is no lookback.
db.append_rating_event(&event(first, "slop", "2020-01-01T00:00:00Z"))
.await
.unwrap();
db.append_rating_event(&event(second, "slop", "2026-08-15T12:00:00Z"))
.await
.unwrap();
assert_eq!(db.slop_authors().await.unwrap(), ["Page Writer", "someone"]);
// A later verdict on the same article replaces the slop one.
db.append_rating_event(&event(second, "cleared", "2026-08-15T13:00:00Z"))
.await
.unwrap();
assert_eq!(db.slop_authors().await.unwrap(), ["Page Writer"]);
}
#[tokio::test] #[tokio::test]
async fn curation_v2_migration_copies_ratings_and_drops_old_tables() { async fn curation_v2_migration_copies_ratings_and_drops_old_tables() {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
+2
View File
@@ -70,6 +70,7 @@ struct RatingLinks {
loved_url: String, loved_url: String,
good_url: String, good_url: String,
not_for_me_url: String, not_for_me_url: String,
slop_url: String,
} }
#[derive(Template)] #[derive(Template)]
@@ -458,6 +459,7 @@ pub fn render_article(
article.id, article.id,
Vote::NotForMe, Vote::NotForMe,
), ),
slop_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Slop),
}), }),
_ => None, _ => None,
}; };
+1 -1
View File
@@ -30,7 +30,7 @@
<p class="understood">{{ line }}</p> <p class="understood">{{ line }}</p>
{% endif %} {% endif %}
{% if let Some(links) = rating %} {% if let Some(links) = rating %}
<p class="rating">Was this a good pick? &#160; <a href="{{ links.loved_url }}">[ Loved it ]</a> &#160; <a href="{{ links.good_url }}">[ Good ]</a> &#160; <a href="{{ links.not_for_me_url }}">[ Not for me ]</a> &#160;&#160;&#160; <a href="{{ read_online_url }}">Read online &#8599;</a></p> <p class="rating">Was this a good pick? &#160; <a href="{{ links.loved_url }}">[ Loved it ]</a> &#160; <a href="{{ links.good_url }}">[ Good ]</a> &#160; <a href="{{ links.not_for_me_url }}">[ Not for me ]</a> &#160; <a href="{{ links.slop_url }}">[ AI slop ]</a> &#160;&#160;&#160; <a href="{{ read_online_url }}">Read online &#8599;</a></p>
{% else %} {% else %}
<p class="read-online"><a href="{{ read_online_url }}">Read online &#8599;</a></p> <p class="read-online"><a href="{{ read_online_url }}">Read online &#8599;</a></p>
{% endif %} {% endif %}
+1
View File
@@ -132,6 +132,7 @@ fn vote(label: &str) -> Result<Vote> {
"loved" => Ok(Vote::Loved), "loved" => Ok(Vote::Loved),
"good" => Ok(Vote::Good), "good" => Ok(Vote::Good),
"not_for_me" => Ok(Vote::NotForMe), "not_for_me" => Ok(Vote::NotForMe),
"slop" => Ok(Vote::Slop),
other => anyhow::bail!("invalid rating label {other:?}"), other => anyhow::bail!("invalid rating label {other:?}"),
} }
} }
+5
View File
@@ -196,6 +196,7 @@ enum RatingListLabel {
Loved, Loved,
Good, Good,
Down, Down,
Slop,
Cleared, Cleared,
} }
@@ -205,6 +206,7 @@ impl RatingListLabel {
Self::Loved => "loved", Self::Loved => "loved",
Self::Good => "good", Self::Good => "good",
Self::Down => "not_for_me", Self::Down => "not_for_me",
Self::Slop => "slop",
Self::Cleared => "cleared", Self::Cleared => "cleared",
} }
} }
@@ -215,6 +217,8 @@ enum RatingSetLabel {
Loved, Loved,
Good, Good,
Down, Down,
/// AI slop: a full negative, and the author's future articles rank much lower.
Slop,
} }
impl RatingSetLabel { impl RatingSetLabel {
@@ -223,6 +227,7 @@ impl RatingSetLabel {
Self::Loved => Vote::Loved, Self::Loved => Vote::Loved,
Self::Good => Vote::Good, Self::Good => Vote::Good,
Self::Down => Vote::NotForMe, Self::Down => Vote::NotForMe,
Self::Slop => Vote::Slop,
} }
} }
} }
+14 -6
View File
@@ -1,5 +1,18 @@
//! Shared construction of explicit rating events. //! Shared construction of explicit rating events.
/// The confirmation shown after an *AI slop* verdict: whether the author
/// penalty can apply depends on the article having an author at all (§9.3).
pub fn slop_message(author: Option<&str>) -> String {
match author.map(str::trim).filter(|author| !author.is_empty()) {
Some(author) => {
format!("Recorded: AI slop — thanks. Future articles by {author} will rank much lower.")
}
None => "Recorded: AI slop — thanks. This article has no known author, so only the \
usual negative rating applies."
.to_string(),
}
}
use crate::config::Config; use crate::config::Config;
use crate::db::{Db, DbError}; use crate::db::{Db, DbError};
use crate::types::{ArticleId, RatingEvent, Vote}; use crate::types::{ArticleId, RatingEvent, Vote};
@@ -15,12 +28,7 @@ pub async fn record_explicit(
note: Option<String>, note: Option<String>,
) -> Result<i64, DbError> { ) -> Result<i64, DbError> {
let (label, value) = match vote { let (label, value) = match vote {
Some(Vote::Loved) => ("loved", Vote::Loved.value(&config.curation.feedback)), Some(vote) => (vote.event_label(), vote.value(&config.curation.feedback)),
Some(Vote::Good) => ("good", Vote::Good.value(&config.curation.feedback)),
Some(Vote::NotForMe) => (
"not_for_me",
Vote::NotForMe.value(&config.curation.feedback),
),
None => ("cleared", 0.0), None => ("cleared", 0.0),
}; };
db.append_rating_event(&RatingEvent { db.append_rating_event(&RatingEvent {
+9 -15
View File
@@ -377,11 +377,7 @@ async fn handle_rating(
} }
}; };
let label = match vote { let label = vote.event_label();
Vote::Loved => "loved",
Vote::Good => "good",
Vote::NotForMe => "not_for_me",
};
let event = RatingEvent { let event = RatingEvent {
id: 0, id: 0,
user_id: None, user_id: None,
@@ -408,11 +404,12 @@ async fn handle_rating(
); );
let message = match vote { let message = match vote {
Vote::Loved => "Recorded: Loved it — thanks.", Vote::Loved => "Recorded: Loved it — thanks.".to_string(),
Vote::Good => "Recorded: Good — thanks.", Vote::Good => "Recorded: Good — thanks.".to_string(),
Vote::NotForMe => "Recorded: Not for me — thanks.", Vote::NotForMe => "Recorded: Not for me — thanks.".to_string(),
Vote::Slop => crate::rate::slop_message(article.author.as_deref()),
}; };
confirmation_page(StatusCode::OK, message, &config, date, article_id, vote) confirmation_page(StatusCode::OK, &message, &config, date, article_id, vote)
} }
/// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first, /// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first,
@@ -644,13 +641,10 @@ fn confirmation_page(
let Some(secret) = config.server.hmac_secret.as_deref() else { let Some(secret) = config.server.hmac_secret.as_deref() else {
return page(status, message, None); return page(status, message, None);
}; };
let choices = [ let choices = [Vote::Loved, Vote::Good, Vote::NotForMe, Vote::Slop]
(Vote::Loved, "Loved it"),
(Vote::Good, "Good"),
(Vote::NotForMe, "Not for me"),
]
.into_iter() .into_iter()
.filter(|(vote, _)| *vote != selected) .filter(|vote| *vote != selected)
.map(|vote| (vote, vote.display()))
.map(|(vote, label)| { .map(|(vote, label)| {
let url = rating_url(&config.server.public_url, secret, date, article_id, vote); let url = rating_url(&config.server.public_url, secret, date, article_id, vote);
format!( format!(
+33
View File
@@ -685,6 +685,10 @@ pub enum Vote {
Good, Good,
#[serde(rename = "down")] #[serde(rename = "down")]
NotForMe, NotForMe,
/// The article reads as AI-generated filler. A full negative, and every
/// later article by the same author is heavily penalized (§9.3).
#[serde(rename = "slop")]
Slop,
} }
impl Vote { impl Vote {
@@ -694,6 +698,27 @@ impl Vote {
Vote::Loved => "loved", Vote::Loved => "loved",
Vote::Good => "good", Vote::Good => "good",
Vote::NotForMe => "down", Vote::NotForMe => "down",
Vote::Slop => "slop",
}
}
/// The `rating_events.label` this vote is stored under.
pub fn event_label(self) -> &'static str {
match self {
Vote::Loved => "loved",
Vote::Good => "good",
Vote::NotForMe => "not_for_me",
Vote::Slop => "slop",
}
}
/// The reader-facing name of the verdict.
pub fn display(self) -> &'static str {
match self {
Vote::Loved => "Loved it",
Vote::Good => "Good",
Vote::NotForMe => "Not for me",
Vote::Slop => "AI slop",
} }
} }
@@ -702,6 +727,7 @@ impl Vote {
"loved" | "up" => Some(Vote::Loved), "loved" | "up" => Some(Vote::Loved),
"good" => Some(Vote::Good), "good" => Some(Vote::Good),
"down" => Some(Vote::NotForMe), "down" => Some(Vote::NotForMe),
"slop" => Some(Vote::Slop),
_ => None, _ => None,
} }
} }
@@ -711,6 +737,7 @@ impl Vote {
Vote::Loved => cfg.loved_value, Vote::Loved => cfg.loved_value,
Vote::Good => cfg.good_value, Vote::Good => cfg.good_value,
Vote::NotForMe => cfg.not_for_me_value, Vote::NotForMe => cfg.not_for_me_value,
Vote::Slop => cfg.slop_value,
} }
} }
} }
@@ -870,6 +897,12 @@ mod tests {
assert_eq!(Vote::parse("up"), Some(Vote::Loved)); assert_eq!(Vote::parse("up"), Some(Vote::Loved));
assert_eq!(Vote::parse("good"), Some(Vote::Good)); assert_eq!(Vote::parse("good"), Some(Vote::Good));
assert_eq!(Vote::parse("down"), Some(Vote::NotForMe)); assert_eq!(Vote::parse("down"), Some(Vote::NotForMe));
assert_eq!(Vote::parse("slop"), Some(Vote::Slop));
assert_eq!(Vote::Slop.as_str(), "slop");
assert_eq!(Vote::Slop.event_label(), "slop");
assert_eq!(Vote::NotForMe.event_label(), "not_for_me");
assert_eq!(Vote::Slop.value(&feedback), -1.0);
assert_eq!(serde_json::to_string(&Vote::Slop).unwrap(), "\"slop\"");
assert_eq!(Vote::Loved.as_str(), "loved"); assert_eq!(Vote::Loved.as_str(), "loved");
assert_eq!(Vote::Good.as_str(), "good"); assert_eq!(Vote::Good.as_str(), "good");
assert_eq!(Vote::NotForMe.as_str(), "down"); assert_eq!(Vote::NotForMe.as_str(), "down");
+2 -2
View File
@@ -65,7 +65,7 @@ pub struct ArticlesQuery {
pub page: Option<u32>, 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 PUBLISHED: [&str; 2] = ["yes", "no"];
const ARTICLE_SORTS: [(&str, &str); 7] = [ const ARTICLE_SORTS: [(&str, &str); 7] = [
@@ -152,7 +152,7 @@ impl ArticleFilters {
match self.rated.as_deref() { match self.rated.as_deref() {
Some("any") => sql.push_str(" AND x.rating IS NOT NULL AND x.rating != 'cleared'"), 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("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 = ?"); sql.push_str(" AND x.rating = ?");
binds.push(Bind::Text(label.to_string())); 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("not_for_me" | "down") => "down",
Some("loved") => "loved", Some("loved") => "loved",
Some("good") => "good", Some("good") => "good",
Some("slop") => "slop",
Some("cleared") => "cleared", Some("cleared") => "cleared",
_ => "", _ => "",
} }
@@ -297,6 +298,7 @@ pub struct SignalsView {
pub neighbours: Vec<NeighbourLine>, pub neighbours: Vec<NeighbourLine>,
pub exploration: bool, pub exploration: bool,
pub auto_include: bool, pub auto_include: bool,
pub slop_author: bool,
pub notes: Vec<String>, pub notes: Vec<String>,
/// A thin hygiene row (`{}`) or unparseable JSON: nothing to show. /// A thin hygiene row (`{}`) or unparseable JSON: nothing to show.
pub empty: bool, pub empty: bool,
@@ -357,6 +359,7 @@ impl SignalsView {
.collect(), .collect(),
exploration: signals.exploration, exploration: signals.exploration,
auto_include: signals.auto_include, auto_include: signals.auto_include,
slop_author: signals.slop_author,
notes: signals.notes.clone(), notes: signals.notes.clone(),
empty, empty,
} }
+7 -1
View File
@@ -244,6 +244,7 @@ fn event_label(widget: &str) -> Option<&'static str> {
"loved" => Some("loved"), "loved" => Some("loved"),
"good" => Some("good"), "good" => Some("good"),
"down" => Some("not_for_me"), "down" => Some("not_for_me"),
"slop" => Some("slop"),
"cleared" => Some("cleared"), "cleared" => Some("cleared"),
_ => None, _ => None,
} }
@@ -255,6 +256,7 @@ fn widget_label(label: &str) -> (&'static str, &'static str) {
"loved" => ("loved", "Loved it"), "loved" => ("loved", "Loved it"),
"good" => ("good", "Good"), "good" => ("good", "Good"),
"not_for_me" | "down" => ("down", "Not for me"), "not_for_me" | "down" => ("down", "Not for me"),
"slop" => ("slop", "AI slop"),
"cleared" => ("cleared", "Cleared"), "cleared" => ("cleared", "Cleared"),
_ => ("", "Unknown"), _ => ("", "Unknown"),
} }
@@ -412,6 +414,8 @@ struct HowValues {
loved: String, loved: String,
good: String, good: String,
not_for_me: String, not_for_me: String,
slop: String,
slop_author_penalty: String,
verdicts_in_prompt: usize, verdicts_in_prompt: usize,
rebuild_interval_days: i64, rebuild_interval_days: i64,
max_ratings_in_rebuild: usize, max_ratings_in_rebuild: usize,
@@ -435,6 +439,8 @@ impl HowValues {
loved: format!("{:+.2}", feedback.loved_value), loved: format!("{:+.2}", feedback.loved_value),
good: format!("{:+.2}", feedback.good_value), good: format!("{:+.2}", feedback.good_value),
not_for_me: format!("{:+.2}", feedback.not_for_me_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, verdicts_in_prompt: feedback.verdicts_in_prompt,
rebuild_interval_days: REBUILD_INTERVAL_DAYS, rebuild_interval_days: REBUILD_INTERVAL_DAYS,
max_ratings_in_rebuild: MAX_RATINGS_IN_REBUILD, max_ratings_in_rebuild: MAX_RATINGS_IN_REBUILD,
@@ -681,7 +687,7 @@ async fn queue_import(
set_flash(&session, "error", message).await?; set_flash(&session, "error", message).await?;
return Ok(Redirect::to("/dashboard/ratings#imports").into_response()); 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?; set_flash(&session, "error", "Choose a valid verdict.".into()).await?;
return Ok(Redirect::to("/dashboard/ratings#imports").into_response()); 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.loved_value", "Weight for a Loved it verdict."),
("curation.feedback.good_value", "Weight for a Good 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.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.feedback.verdicts_in_prompt", "Recent explicit verdicts included in the system prompt."),
("curation.ranking.triage_max", "Eligible articles the triage LLM reads."), ("curation.ranking.triage_max", "Eligible articles the triage LLM reads."),
("curation.ranking.deep_keep", "Size of the deep-assessment set. Must be >= shortlist_keep."), ("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.rating_half_life_days", "Ratings decay with this half-life."),
("curation.ranking.neighbour_k", "Rated neighbours per side for the knn signal."), ("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.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_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.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."), ("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"); 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] #[tokio::test]
async fn toc_numbers_chapters_across_sections_and_tracks_the_reader() { async fn toc_numbers_chapters_across_sections_and_tracks_the_reader() {
let (_dir, db, source) = seeded_issue(true).await; 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("not_for_me" | "down") => "down",
Some("loved") => "loved", Some("loved") => "loved",
Some("good") => "good", Some("good") => "good",
Some("slop") => "slop",
Some("cleared") => "cleared", Some("cleared") => "cleared",
_ => "", _ => "",
} }
@@ -122,12 +123,12 @@ pub async fn post(
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated { let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/rate".into(), 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!( return Err(WebError::BadRequest(format!(
"article {} does not exist", "article {} does not exist",
input.article_id input.article_id
))); )));
} };
let issue_date = match input let issue_date = match input
.issue_date .issue_date
@@ -147,27 +148,24 @@ pub async fn post(
} }
}; };
let config = state.config(); let config = state.config();
let (event_label, value, response_label, flash_label) = match input.label.as_str() { let (event_label, value, response_label, flash_text) = match input.label.as_str() {
"loved" => ( "cleared" => ("cleared", 0.0, "cleared", "Rated: Cleared".to_string()),
"loved", "slop" => (
Vote::Loved.value(&config.curation.feedback), "slop",
"loved", Vote::Slop.value(&config.curation.feedback),
"Loved it", "slop",
crate::rate::slop_message(article.author.as_deref()),
), ),
"good" => ( widget => {
"good", let vote = Vote::parse(widget)
Vote::Good.value(&config.curation.feedback), .ok_or_else(|| WebError::BadRequest("invalid rating label".into()))?;
"good", (
"Good", vote.event_label(),
), vote.value(&config.curation.feedback),
"down" => ( vote.as_str(),
"not_for_me", format!("Rated: {}", vote.display()),
Vote::NotForMe.value(&config.curation.feedback), )
"down", }
"Not for me",
),
"cleared" => ("cleared", 0.0, "cleared", "Cleared"),
_ => return Err(WebError::BadRequest("invalid rating label".into())),
}; };
let note = input let note = input
.note .note
@@ -207,7 +205,7 @@ pub async fn post(
"flash", "flash",
Flash { Flash {
kind: "success".into(), kind: "success".into(),
text: format!("Rated: {flash_label}"), text: flash_text,
}, },
) )
.await .await
@@ -223,6 +221,7 @@ mod tests {
#[test] #[test]
fn database_and_widget_labels_are_mapped_explicitly() { fn database_and_widget_labels_are_mapped_explicitly() {
assert_eq!(web_label(Some("not_for_me")), "down"); 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(Some("cleared")), "cleared");
assert_eq!(web_label(None), ""); 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 { @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.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.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.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); } .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[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="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="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 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:has(button[data-label]:not(.clear).active) button.clear, .rating button.clear.active { @apply visible; }
.rating button.clear.active { @apply no-underline; } .rating button.clear.active { @apply no-underline; }
@@ -288,7 +288,7 @@
td .rating-note-label { @apply sr-only; } td .rating-note-label { @apply sr-only; }
td .rating-note input { @apply text-xs; } td .rating-note input { @apply text-xs; }
/* the ratings table's verdict cell: one column, buttons then note, nothing wraps */ /* 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-actions { @apply flex w-full min-h-0 items-center; }
.rating-cell .rating button.clear { @apply ml-auto; } .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="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="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="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> <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> </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 %} {% 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"> {% 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> <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> <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.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.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 %} {% 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"> <div class="disclosure-body">
<form method="post" action="/dashboard/ratings/import" class="filters"> <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 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> <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> <div class="filter-actions"><button class="btn btn-primary" type="submit">Queue import</button></div>
</form> </form>
@@ -34,10 +34,11 @@
<ol class="list-decimal space-y-3 pl-5 marker:text-muted"> <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>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>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>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> </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> </div>
</details> </details>
@@ -46,7 +47,7 @@
{% if tab == "events" %} {% if tab == "events" %}
<form class="filters" method="get" action="/dashboard/ratings"> <form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="events"> <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>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>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> <label>From <input type="date" name="from" value="{{ filter_from }}"></label>
@@ -75,7 +76,7 @@
{% else %} {% else %}
<form class="filters" method="get" action="/dashboard/ratings"> <form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="current"> <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>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>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> <label>Title <input type="search" name="q" value="{{ filter_q }}" placeholder="contains…"></label>
+9 -1
View File
@@ -340,10 +340,17 @@ fn rating_links_carry_the_spec_token() {
let loved = build::rating_token("integration-secret", date, 1, Vote::Loved); let loved = build::rating_token("integration-secret", date, 1, Vote::Loved);
let good = build::rating_token("integration-secret", date, 1, Vote::Good); let good = build::rating_token("integration-secret", date, 1, Vote::Good);
let down = build::rating_token("integration-secret", date, 1, Vote::NotForMe); let down = build::rating_token("integration-secret", date, 1, Vote::NotForMe);
let slop = build::rating_token("integration-secret", date, 1, Vote::Slop);
assert_eq!(loved.len(), 16); assert_eq!(loved.len(), 16);
assert_ne!(loved, good); assert_ne!(loved, good);
assert_ne!(good, down); assert_ne!(good, down);
for (segment, token) in [("loved", loved), ("good", good), ("down", down)] { assert_ne!(down, slop);
for (segment, token) in [
("loved", loved),
("good", good),
("down", down),
("slop", slop),
] {
assert!(chapter.xhtml.contains(&format!( assert!(chapter.xhtml.contains(&format!(
"https://daily.hallada.net/r/2026-08-15/1/{segment}?t={token}" "https://daily.hallada.net/r/2026-08-15/1/{segment}?t={token}"
))); )));
@@ -351,6 +358,7 @@ fn rating_links_carry_the_spec_token() {
assert!(chapter.xhtml.contains("[ Loved it ]")); assert!(chapter.xhtml.contains("[ Loved it ]"));
assert!(chapter.xhtml.contains("[ Good ]")); assert!(chapter.xhtml.contains("[ Good ]"));
assert!(chapter.xhtml.contains("[ Not for me ]")); assert!(chapter.xhtml.contains("[ Not for me ]"));
assert!(chapter.xhtml.contains("[ AI slop ]"));
} }
#[test] #[test]