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.
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 /
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
deep set was admitted, whether the learned signals were active, the ten
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 serve # public site, private dashboard, OPDS, ratings, downloads
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 clear --url https://example.com/article
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.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.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.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). |
@@ -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
signal is absent. Ratings decay with `rating_half_life_days` (60) over
`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),
`semantic_min_words` (300), `exploration_slots` (5), `[curation.ranking.quotas]`
(`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/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;'
# 8. Watch cost and quality for a week
+2
View File
@@ -132,6 +132,7 @@ sections = [
loved_value = 1.0
good_value = 0.35
not_for_me_value = -1.0
slop_value = -1.0 # AI slop: a full negative; the author penalty is below
verdicts_in_prompt = 60
# Every weight, quota, gate and threshold of the personalized ranker. The
@@ -150,6 +151,7 @@ knn_floor = 8
knn_full = 25
feed_floor = 15
feed_full = 40
slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict
semantic_min_words = 300
exploration_slots = 5
embedding_retention_days = 120 # `features prune`: unrated, unpublished vectors
+16
View File
@@ -468,6 +468,10 @@ pub struct RankingConfig {
pub knn_full: usize,
pub feed_floor: 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 exploration_slots: usize,
pub embedding_retention_days: i64,
@@ -492,6 +496,7 @@ impl Default for RankingConfig {
knn_full: 25,
feed_floor: 15,
feed_full: 40,
slop_author_penalty: 0.75,
semantic_min_words: 300,
exploration_slots: 5,
embedding_retention_days: 120,
@@ -603,6 +608,9 @@ pub struct FeedbackConfig {
pub loved_value: f64,
pub good_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,
}
@@ -612,6 +620,7 @@ impl Default for FeedbackConfig {
loved_value: 1.0,
good_value: 0.35,
not_for_me_value: -1.0,
slop_value: -1.0,
verdicts_in_prompt: 60,
}
}
@@ -1180,6 +1189,11 @@ impl Config {
"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) {
return Err(ConfigError::Invalid(
"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.profile_path, PathBuf::from("data/profile.md"));
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.xtc.format, XtcFormat::Xtch);
assert_eq!(c.curation.sections.len(), 8);
+1
View File
@@ -211,6 +211,7 @@ fn render_candidate(candidate: &Candidate) -> String {
"loved" => "LOVED",
"good" => "GOOD",
"not_for_me" | "down" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other,
};
format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos)
+1
View File
@@ -172,6 +172,7 @@ fn render_candidate(candidate: &Candidate) -> String {
"loved" => "LOVED",
"good" => "GOOD",
"not_for_me" | "down" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other,
};
format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos)
+1
View File
@@ -162,6 +162,7 @@ fn verdict_label(label: &str) -> &str {
"loved" => "LOVED",
"good" => "GOOD",
"not_for_me" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other,
}
}
+21 -2
View File
@@ -13,7 +13,8 @@ pub struct RankSummary {
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) {
let indices = (0..candidates.len()).collect::<Vec<_>>();
calculate_utility_for(candidates, &indices, configured);
@@ -88,7 +89,8 @@ fn calculate_utility_for(
.iter()
.map(|(_, weight, value)| weight / total * value)
.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]
fn duplicates_cluster_and_the_third_is_suppressed() {
let ranking = ranking(3, 0, 2, 0.85);
+123 -5
View File
@@ -72,11 +72,29 @@ pub struct Signals {
pub notes: Vec<String>,
/// Preliminary blend on a 0–100 scale; `None` when nothing is present.
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).
#[serde(skip)]
pub knn_gate: f64,
#[serde(skip)]
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 {
@@ -161,6 +179,9 @@ pub struct PreferenceState {
pub examples: Vec<RatedExample>,
feed_rates: HashMap<FeedId, 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 knn_gate: f64,
pub feed_gate: f64,
@@ -183,10 +204,36 @@ impl PreferenceState {
examples,
feed_rates,
author_rates,
slop_authors: HashSet::new(),
slop_author_penalty: ranking.slop_author_penalty,
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
/// `article_embeddings`; ratings without an embedding are skipped (§9.2).
pub async fn load(
@@ -227,7 +274,8 @@ impl PreferenceState {
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 {
@@ -253,11 +301,14 @@ impl PreferenceState {
rated_with_embeddings = self.examples.len(),
knn_gate = self.knn_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.knn_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.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 {
signals.notes.push(format!(
"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
/// 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> {
let candidates = [
("interest", configured.interest, 1.0),
@@ -646,7 +706,8 @@ pub fn preliminary_blend(signals: &mut Signals, configured: &PreliminaryWeights)
.iter()
.map(|(_, weight, norm)| weight / total * norm)
.sum::<f64>()
* 100.0;
* 100.0
* signals.slop_factor();
signals.preliminary = Some(blend);
Some(blend)
}
@@ -964,6 +1025,63 @@ mod tests {
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]
fn feed_is_absent_when_the_gate_is_closed() {
let ranking = ranking(); // feed_floor 15
+7 -3
View File
@@ -129,6 +129,9 @@ pub struct SignalsJson {
pub exploration: bool,
#[serde(default)]
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)]
pub notes: Vec<String>,
}
@@ -177,6 +180,7 @@ pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String {
neighbours: signals.neighbours.clone(),
exploration: false,
auto_include,
slop_author: signals.slop_author,
notes: signals.notes.clone(),
})
.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!(
out,
"flags: exploration={} auto_include={}",
signals.exploration, signals.auto_include
"flags: exploration={} auto_include={} slop_author={}",
signals.exploration, signals.auto_include, signals.slop_author
);
}
for note in &signals.notes {
+1
View File
@@ -154,6 +154,7 @@ fn render_candidate(candidate: &Candidate) -> String {
"loved" => "LOVED",
"good" => "GOOD",
"not_for_me" | "down" => "NOT FOR ME",
"slop" => "AI SLOP",
other => other,
};
format!("{label} \"{}\" ({:.2})", neighbour.title, neighbour.cos)
+88
View File
@@ -719,6 +719,33 @@ impl Db {
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(
&self,
lookback_days: i64,
@@ -1551,6 +1578,67 @@ mod tests {
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]
async fn curation_v2_migration_copies_ratings_and_drops_old_tables() {
let pool = SqlitePoolOptions::new()
+2
View File
@@ -70,6 +70,7 @@ struct RatingLinks {
loved_url: String,
good_url: String,
not_for_me_url: String,
slop_url: String,
}
#[derive(Template)]
@@ -458,6 +459,7 @@ pub fn render_article(
article.id,
Vote::NotForMe,
),
slop_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Slop),
}),
_ => None,
};
+1 -1
View File
@@ -30,7 +30,7 @@
<p class="understood">{{ line }}</p>
{% endif %}
{% 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 %}
<p class="read-online"><a href="{{ read_online_url }}">Read online &#8599;</a></p>
{% endif %}
+1
View File
@@ -132,6 +132,7 @@ fn vote(label: &str) -> Result<Vote> {
"loved" => Ok(Vote::Loved),
"good" => Ok(Vote::Good),
"not_for_me" => Ok(Vote::NotForMe),
"slop" => Ok(Vote::Slop),
other => anyhow::bail!("invalid rating label {other:?}"),
}
}
+5
View File
@@ -196,6 +196,7 @@ enum RatingListLabel {
Loved,
Good,
Down,
Slop,
Cleared,
}
@@ -205,6 +206,7 @@ impl RatingListLabel {
Self::Loved => "loved",
Self::Good => "good",
Self::Down => "not_for_me",
Self::Slop => "slop",
Self::Cleared => "cleared",
}
}
@@ -215,6 +217,8 @@ enum RatingSetLabel {
Loved,
Good,
Down,
/// AI slop: a full negative, and the author's future articles rank much lower.
Slop,
}
impl RatingSetLabel {
@@ -223,6 +227,7 @@ impl RatingSetLabel {
Self::Loved => Vote::Loved,
Self::Good => Vote::Good,
Self::Down => Vote::NotForMe,
Self::Slop => Vote::Slop,
}
}
}
+14 -6
View File
@@ -1,5 +1,18 @@
//! 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::db::{Db, DbError};
use crate::types::{ArticleId, RatingEvent, Vote};
@@ -15,12 +28,7 @@ pub async fn record_explicit(
note: Option<String>,
) -> Result<i64, DbError> {
let (label, value) = match vote {
Some(Vote::Loved) => ("loved", Vote::Loved.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),
),
Some(vote) => (vote.event_label(), vote.value(&config.curation.feedback)),
None => ("cleared", 0.0),
};
db.append_rating_event(&RatingEvent {
+9 -15
View File
@@ -377,11 +377,7 @@ async fn handle_rating(
}
};
let label = match vote {
Vote::Loved => "loved",
Vote::Good => "good",
Vote::NotForMe => "not_for_me",
};
let label = vote.event_label();
let event = RatingEvent {
id: 0,
user_id: None,
@@ -408,11 +404,12 @@ async fn handle_rating(
);
let message = match vote {
Vote::Loved => "Recorded: Loved it — thanks.",
Vote::Good => "Recorded: Good — thanks.",
Vote::NotForMe => "Recorded: Not for me — thanks.",
Vote::Loved => "Recorded: Loved it — thanks.".to_string(),
Vote::Good => "Recorded: Good — thanks.".to_string(),
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,
@@ -644,13 +641,10 @@ fn confirmation_page(
let Some(secret) = config.server.hmac_secret.as_deref() else {
return page(status, message, None);
};
let choices = [
(Vote::Loved, "Loved it"),
(Vote::Good, "Good"),
(Vote::NotForMe, "Not for me"),
]
let choices = [Vote::Loved, Vote::Good, Vote::NotForMe, Vote::Slop]
.into_iter()
.filter(|(vote, _)| *vote != selected)
.filter(|vote| *vote != selected)
.map(|vote| (vote, vote.display()))
.map(|(vote, label)| {
let url = rating_url(&config.server.public_url, secret, date, article_id, vote);
format!(
+33
View File
@@ -685,6 +685,10 @@ pub enum Vote {
Good,
#[serde(rename = "down")]
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 {
@@ -694,6 +698,27 @@ impl Vote {
Vote::Loved => "loved",
Vote::Good => "good",
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),
"good" => Some(Vote::Good),
"down" => Some(Vote::NotForMe),
"slop" => Some(Vote::Slop),
_ => None,
}
}
@@ -711,6 +737,7 @@ impl Vote {
Vote::Loved => cfg.loved_value,
Vote::Good => cfg.good_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("good"), Some(Vote::Good));
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::Good.as_str(), "good");
assert_eq!(Vote::NotForMe.as_str(), "down");
+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>
+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 good = build::rating_token("integration-secret", date, 1, Vote::Good);
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_ne!(loved, good);
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!(
"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("[ Good ]"));
assert!(chapter.xhtml.contains("[ Not for me ]"));
assert!(chapter.xhtml.contains("[ AI slop ]"));
}
#[test]