Drop re-published copies of one story from related lists

The ingest dedupe only merges within one run, so a story an outlet
re-publishes on another day under a new URL and a reworded headline lands
in the archive twice and both copies showed up as related links. Links now
also skip an article from the same site as the pick or a closer link when
60% of their title words match, besides exact title matches anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJmv3WED4w8S3GV7mMjS7
This commit is contained in:
2026-10-12 18:50:24 +00:00
co-authored by Claude Opus 5.5
parent 08b15c42b4
commit 682fc4a6fb
+107 -33
View File
@@ -24,9 +24,13 @@ const PER_ARTICLE: usize = 5;
const MIN_COSINE: f64 = 0.55;
/// Embedding rows decoded per statement, so memory stays flat as the archive grows.
const SCAN_PAGE: i64 = 1000;
/// Candidates kept per pick between pages: enough headroom for the title
/// de-duplication at the end to still fill [`PER_ARTICLE`].
/// Candidates kept per pick between pages: enough headroom for the
/// same-story filter at the end to still fill [`PER_ARTICLE`].
const KEEP: usize = PER_ARTICLE * 4;
/// Share of title words two articles from one site must have in common to be
/// one story re-published under a new URL ("…after AI accusations" and
/// "…after AI allegations").
const SAME_SITE_TITLE_OVERLAP: f64 = 0.6;
/// What [`refresh`] wrote.
#[derive(Debug, Default, PartialEq)]
@@ -39,8 +43,8 @@ pub struct Refreshed {
/// empty) in one pass over the embedding cache.
///
/// Excluded: the pick itself, the rest of its issue, articles that only have a
/// feed excerpt (their page would be near-empty), and articles whose title
/// matches the pick's or a closer link's (syndicated copies of one story).
/// feed excerpt (their page would be near-empty), and articles that are the
/// pick's or a closer link's story again (see [`Story::same_as`]).
pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<Refreshed> {
let dates = if dates.is_empty() {
sqlx::query_scalar::<_, String>("SELECT date FROM issues ORDER BY date")
@@ -53,11 +57,11 @@ pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<R
dates.to_vec()
};
// Every pick of every requested issue, with its title for de-duplication.
// Every pick of every requested issue, with its story key for de-duplication.
let mut groups: Vec<Group> = Vec::with_capacity(dates.len());
for date in &dates {
let rows = sqlx::query(
"SELECT ia.article_id, a.title FROM issue_articles ia
"SELECT ia.article_id, a.title, a.canonical_url FROM issue_articles ia
JOIN articles a ON a.id = ia.article_id
WHERE ia.issue_date = ? ORDER BY ia.section, ia.position",
)
@@ -71,7 +75,7 @@ pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<R
.iter()
.map(|row| Target {
article_id: row.get("article_id"),
title_key: title_key(row.get::<Option<String>, _>("title").as_deref()),
story: Story::from_row(row),
vector: Vec::new(),
best: Vec::new(),
})
@@ -99,7 +103,7 @@ pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<R
let mut after = i64::MIN;
loop {
let rows = sqlx::query(
"SELECT ae.article_id, ae.embedding, a.title
"SELECT ae.article_id, ae.embedding, a.title, a.canonical_url
FROM article_embeddings ae JOIN articles a ON a.id = ae.article_id
WHERE ae.model = ? AND ae.dimension = ? AND a.excerpt_only = 0
AND ae.article_id > ?
@@ -125,7 +129,7 @@ pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<R
continue;
}
};
let title = title_key(row.get::<Option<String>, _>("title").as_deref());
let story = Story::from_row(row);
for group in &mut groups {
if group.members.contains(&candidate) {
continue;
@@ -133,7 +137,7 @@ pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<R
for pick in &mut group.picks {
let cosine = dot(&pick.vector, &vector)?;
if cosine.is_finite() && cosine >= MIN_COSINE {
pick.best.push((candidate, cosine, title.clone()));
pick.best.push((candidate, cosine, story.clone()));
}
}
}
@@ -161,16 +165,18 @@ pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<R
.execute(&mut *tx)
.await?;
for pick in &group.picks {
let mut seen: HashSet<&str> = HashSet::new();
if !pick.title_key.is_empty() {
seen.insert(pick.title_key.as_str());
let mut seen = vec![&pick.story];
let mut chosen = Vec::new();
for (id, cosine, story) in &pick.best {
if chosen.len() == PER_ARTICLE {
break;
}
if !seen.iter().any(|earlier| earlier.same_as(story)) {
seen.push(story);
chosen.push((id, cosine));
}
}
let chosen = pick
.best
.iter()
.filter(|(_, _, title)| title.is_empty() || seen.insert(title))
.take(PER_ARTICLE);
for (position, (related_id, cosine, _)) in chosen.enumerate() {
for (position, (related_id, cosine)) in chosen.into_iter().enumerate() {
sqlx::query(
"INSERT INTO issue_related (issue_date, article_id, position, related_id, cosine)
VALUES (?, ?, ?, ?, ?)",
@@ -229,20 +235,60 @@ struct Group {
struct Target {
article_id: ArticleId,
title_key: String,
story: Story,
vector: Vec<f32>,
/// (candidate, cosine, title key), closest first after each scan page.
best: Vec<(ArticleId, f64, String)>,
/// (candidate, cosine, story), closest first after each scan page.
best: Vec<(ArticleId, f64, Story)>,
}
/// Lowercased letters and digits only, so "Foo: Bar" and "foo — bar" collide.
fn title_key(title: Option<&str>) -> String {
title
.unwrap_or_default()
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
/// What decides whether two articles are one story told twice. The ingest
/// dedupe only merges within one run, so a story re-published on another day
/// under a new URL reaches the archive as a second article.
#[derive(Clone, Debug)]
struct Story {
/// Canonical URL host without `www.`.
site: String,
/// Lowercased title words.
words: HashSet<String>,
}
impl Story {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Self {
// Re-canonicalized so legacy rows still wrapped in a Scour redirect
// count as the outlet's site, not Scour's.
let raw: String = row.get("canonical_url");
let site = crate::dedupe::canonical_url(&raw)
.and_then(|canonical| url::Url::parse(&canonical).ok())
.and_then(|url| {
url.host_str()
.map(|host| host.trim_start_matches("www.").to_string())
})
.unwrap_or_default();
let words = row
.get::<Option<String>, _>("title")
.unwrap_or_default()
.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_lowercase)
.collect();
Self { site, words }
}
/// The same title anywhere (syndication), or mostly the same title on the
/// same site (a re-slugged republish).
fn same_as(&self, other: &Story) -> bool {
if self.words.is_empty() || other.words.is_empty() {
return false;
}
if self.words == other.words {
return true;
}
let shared = self.words.intersection(&other.words).count() as f64;
let union = self.words.union(&other.words).count() as f64;
!self.site.is_empty()
&& self.site == other.site
&& shared / union >= SAME_SITE_TITLE_OVERLAP
}
}
#[cfg(test)]
@@ -365,8 +411,36 @@ mod tests {
}
#[test]
fn title_keys_ignore_case_and_punctuation() {
assert_eq!(title_key(Some("Foo: Bar — Baz!")), "foobarbaz");
assert_eq!(title_key(None), "");
fn stories_match_on_identical_titles_or_close_titles_from_one_site() {
let story = |site: &str, title: &str| Story {
site: site.into(),
words: title
.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_lowercase)
.collect(),
};
let accusations = story(
"the-independent.com",
"The bestselling novel pulled from prestigious literary prize after AI accusations",
);
let allegations = story(
"the-independent.com",
"The bestselling novel pulled from prestigious literary prize after AI allegations",
);
let conversation = story(
"theconversation.com",
"A bestselling novel was pulled from a French literary prize after AI accusations – what next?",
);
assert!(accusations.same_as(&allegations), "a re-slugged republish");
assert!(
!accusations.same_as(&conversation),
"other outlets' coverage stays"
);
assert!(
story("a.com", "Foo: Bar").same_as(&story("b.com", "foo — bar")),
"syndicated copies match across sites"
);
assert!(!story("a.com", "").same_as(&story("a.com", "")));
}
}