Curation v2 step 4: triage replaces the gate

DeepSeek triage over every eligible article (plan §10) with cached
assessments in article_assessments, the union admission with quotas and
exploration slots (§11), hygiene moved to admit.rs with the churn rule
reading assessments, prefilter.rs reduced to hygiene and text heuristic,
prefilter_keep removed in favour of curation.ranking.deep_keep, the
scores table dropped (migration 0003), and --rescore on generate.

Implemented by Codex (gpt-5.4, high effort) from
docs/plans/curation-v2-briefs/step4.md; reviewed against plan §10–§11.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 15:25:04 +00:00
co-authored by Claude Fable 5.1
parent 76527e3821
commit 10f4afd091
18 changed files with 1927 additions and 816 deletions
+7 -51
View File
@@ -16,8 +16,8 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, S
use sqlx::{Row, SqlitePool};
use crate::types::{
Article, ArticleId, Entry, EntryId, Facets, LlmScore, Pick, RatedArticle, RatingEvent,
SocialRef, SocialSource, SourceRef,
Article, ArticleId, Entry, EntryId, Facets, Pick, RatedArticle, RatingEvent, SocialRef,
SocialSource, SourceRef,
};
/// Embedded migrations from `./migrations` (implementation notes §1).
@@ -367,23 +367,6 @@ impl Db {
Ok(rows.iter().map(|row| row.get::<i64, _>("id")).collect())
}
/// Articles the LLM scored below `threshold` within the last `days` (§3.5).
pub async fn recently_low_scored_ids(
&self,
threshold: f64,
since: Date,
) -> Result<Vec<ArticleId>> {
let rows = sqlx::query(
"SELECT DISTINCT article_id FROM scores
WHERE llm_score IS NOT NULL AND llm_score < ? AND run_date >= ?",
)
.bind(threshold)
.bind(since.to_string())
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| r.get::<i64, _>("article_id")).collect())
}
// -----------------------------------------------------------------
// social (§3.4)
// -----------------------------------------------------------------
@@ -422,37 +405,6 @@ impl Db {
rows.iter().map(social_from_row).collect()
}
// -----------------------------------------------------------------
// scores (§3.5, §3.6)
// -----------------------------------------------------------------
pub async fn upsert_score(
&self,
article_id: ArticleId,
run_date: Date,
prefilter_score: Option<f64>,
llm: Option<&LlmScore>,
) -> Result<()> {
sqlx::query(
"INSERT INTO scores (article_id, run_date, prefilter_score, llm_score, llm_category, rationale)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(article_id, run_date) DO UPDATE SET
prefilter_score = COALESCE(excluded.prefilter_score, scores.prefilter_score),
llm_score = COALESCE(excluded.llm_score, scores.llm_score),
llm_category = COALESCE(excluded.llm_category, scores.llm_category),
rationale = COALESCE(excluded.rationale, scores.rationale)",
)
.bind(article_id)
.bind(run_date.to_string())
.bind(prefilter_score)
.bind(llm.map(|l| l.score))
.bind(llm.map(|l| l.category.as_str()))
.bind(llm.map(|l| l.rationale.as_str()))
.execute(&self.pool)
.await?;
Ok(())
}
// -----------------------------------------------------------------
// issues + lineup (§3.10)
// -----------------------------------------------------------------
@@ -1312,6 +1264,10 @@ mod tests {
.execute(&pool)
.await
.unwrap();
sqlx::raw_sql(include_str!("../migrations/0003_drop_scores.sql"))
.execute(&pool)
.await
.unwrap();
let rows = sqlx::query(
"SELECT article_id, issue_date, kind, source, label, value, event_at
@@ -1339,7 +1295,7 @@ mod tests {
.unwrap();
assert!(!tables.iter().any(|table| table == "ratings"));
assert!(!tables.iter().any(|table| table == "feed_priors"));
assert!(tables.iter().any(|table| table == "scores"));
assert!(!tables.iter().any(|table| table == "scores"));
for expected in [
"rating_events",
"article_embeddings",