Add related articles under web issue articles and keep embeddings forever

Each pick's web page now lists up to five related articles: the nearest
embedded articles across the whole archive (cosine >= 0.55), excluding the
rest of the issue, excerpt-only articles and same-title copies. They are
computed once per issue after publishing and stored in issue_related
(migration 0014), since the full embedding scan is too slow per page view;
`features related [--date]` and the related-refresh job backfill older issues.

Related articles that ran in an issue link to that issue page; the rest link
to a new signed-in /articles/{id} page rendering the stored body with no
summary. /articles/{id} for a published article redirects to its issue page.

embedding_retention_days = 0 now means keep every vector (previously it would
have deleted every unrated, unpublished embedding) and is the new default, so
related lists can reach the whole archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJmv3WED4w8S3GV7mMjS7
This commit is contained in:
thalladaandClaude Opus 5.5 committed 2026-10-11 21:34:31 +00:00
1 parent bf6a8d0129
commit 03f0a454be
17 files changed
+788 -30

No files matched your search

+221
View File
@@ -960,6 +960,36 @@ struct ArticleTemplate {
previous: Option<ArticleLink>,
next: Option<ArticleLink>,
issue_href: String,
related: Vec<RelatedView>,
}
/// An archive article that never ran in an issue, reached from a related list:
/// the stored body with no summary, rating or issue navigation.
#[derive(Template)]
#[template(path = "article_standalone.html")]
struct StandaloneArticleTemplate {
page: Page,
title: String,
source_url: String,
byline: Option<String>,
source: Source,
meta_line: String,
social_line: Option<String>,
excerpt_only: bool,
body_html: String,
read_online_url: String,
dashboard_href: String,
}
/// One entry of the "Related" list under an issue article.
#[derive(Debug)]
struct RelatedView {
title: String,
href: String,
author: Option<String>,
source: Source,
reading_minutes: i64,
date: String,
}
#[derive(Template)]
@@ -1114,6 +1144,7 @@ pub async fn article(
let is_admin = viewer.role == crate::web::users::Role::Admin;
let config = state.config();
let active_nav = if view.is_latest { "latest" } else { "archive" };
let related = related_views(&state.db, &config, is_admin, date, article_id).await?;
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
page.flash = take_flash(&session).await?;
Ok(Html(ArticleTemplate {
@@ -1150,10 +1181,104 @@ pub async fn article(
previous,
next,
issue_href: issue_href(date),
related,
})
.into_response())
}
/// `/articles/{id}`: any stored article on its own page. One that ran in an
/// issue redirects to its (latest) issue page, which carries the summary and
/// related list.
pub async fn standalone(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Path(article_id): Path<ArticleId>,
) -> Result<Response, WebError> {
let viewer = auth
.user()
.await
.map(Viewer::from)
.ok_or_else(|| WebError::Unauthenticated {
next: standalone_href(article_id),
})?;
let Some(article) = state.db.get_article(article_id).await? else {
return Err(WebError::NotFound);
};
if let Some(date) = crate::related::latest_issues(&state.db, &[article_id])
.await?
.remove(&article_id)
{
return Ok(Redirect::to(&article_href(date, article_id)).into_response());
}
let is_admin = viewer.role == crate::web::users::Role::Admin;
let config = state.config();
let mut page = Page::new(article.title.clone(), Some(viewer), "archive");
page.flash = take_flash(&session).await?;
Ok(Html(StandaloneArticleTemplate {
page,
title: article.title.clone(),
source_url: article.canonical_url.clone(),
byline: article.author.as_ref().map(|author| format!("By {author}")),
source: Source::new(&article, &config, is_admin),
meta_line: format!(
"{} words · ~{} min read",
thousands(article.word_count),
article.reading_minutes()
),
social_line: chapters::social_line(&article.social),
excerpt_only: article.excerpt_only,
body_html: prepare_body(&article.content_html),
read_online_url: article.url.clone(),
dashboard_href: format!("/dashboard/articles/{}", article.id),
})
.into_response())
}
/// The stored related links of one pick, each pointing at the issue page it
/// ran on or, failing that, its standalone page.
async fn related_views(
db: &Db,
config: &Config,
is_admin: bool,
date: Date,
article_id: ArticleId,
) -> anyhow::Result<Vec<RelatedView>> {
let links = crate::related::for_article(db, date, article_id).await?;
if links.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<ArticleId> = links.iter().map(|link| link.article_id).collect();
let articles = db.get_articles(&ids).await?;
let issues = crate::related::latest_issues(db, &ids).await?;
Ok(links
.iter()
.filter_map(|link| {
let article = articles.get(&link.article_id)?;
let href = match issues.get(&link.article_id) {
Some(date) => article_href(*date, article.id),
None => standalone_href(article.id),
};
Some(RelatedView {
title: article.title.clone(),
href,
author: article.author.clone(),
source: Source::new(article, config, is_admin),
reading_minutes: article.reading_minutes(),
date: short_date(article.published_at.unwrap_or(article.first_seen), config),
})
})
.collect())
}
/// "Aug 15, 2026" in the configured zone.
fn short_date(timestamp: jiff::Timestamp, config: &Config) -> String {
match config.tz() {
Ok(tz) => timestamp.to_zoned(tz).strftime("%b %-d, %Y").to_string(),
Err(_) => timestamp.strftime("%b %-d, %Y").to_string(),
}
}
pub async fn world(
State(state): State<AppState>,
auth: AuthSession,
@@ -1335,6 +1460,10 @@ fn article_href(date: Date, article_id: ArticleId) -> String {
format!("/issues/{date}/articles/{article_id}")
}
fn standalone_href(article_id: ArticleId) -> String {
format!("/articles/{article_id}")
}
fn issue_href(date: Date) -> String {
format!("/issues/{date}")
}
@@ -2198,6 +2327,98 @@ mod tests {
assert!(!reader.contains(&feed_href));
}
#[tokio::test]
async fn related_links_reach_issue_pages_and_standalone_archive_articles() {
let (_dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let pick = &source.lineup.picks[0].article;
let sibling = &source.lineup.picks[1].article;
let mut archived = pick.clone();
archived.id = 0;
archived.canonical_url = "https://example.com/archived-story".into();
archived.title = "An archived story nobody picked".into();
archived.content_html = "<p>Archived body text.</p>".into();
let archived_id = db.upsert_article(&archived).await.unwrap();
for (position, related_id) in [archived_id, sibling.id].into_iter().enumerate() {
sqlx::query(
"INSERT INTO issue_related (issue_date, article_id, position, related_id, cosine)
VALUES (?, ?, ?, ?, 0.8)",
)
.bind(source.meta.date.to_string())
.bind(pick.id)
.bind(position as i64)
.bind(related_id)
.execute(db.pool())
.await
.unwrap();
}
let app = crate::server::router(crate::server::AppState::new(
db,
crate::config::Config::default(),
None,
));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let get = |uri: String, cookie: Option<String>| {
let app = app.clone();
async move {
let mut request = Request::builder().uri(uri);
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
app.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap()
}
};
let page = response_text(
get(
format!("/issues/{}/articles/{}", source.meta.date, pick.id),
Some(cookie.clone()),
)
.await,
)
.await;
let related = page
.split_once("id=\"related-heading\"")
.expect("related list")
.1;
let archived_at = related
.find(&format!("href=\"/articles/{archived_id}\""))
.expect("unpicked article links to its standalone page");
let sibling_at = related
.find(&format!(
"href=\"/issues/{}/articles/{}\"",
source.meta.date, sibling.id
))
.expect("a picked article links to its issue page");
assert!(archived_at < sibling_at, "stored order is kept");
let standalone = get(format!("/articles/{archived_id}"), Some(cookie.clone())).await;
assert_eq!(standalone.status(), StatusCode::OK);
let standalone = response_text(standalone).await;
assert!(standalone.contains("An archived story nobody picked"));
assert!(standalone.contains("Archived body text."));
let picked = get(format!("/articles/{}", pick.id), Some(cookie)).await;
assert_eq!(picked.status(), StatusCode::SEE_OTHER);
assert_eq!(
picked.headers()[header::LOCATION],
format!("/issues/{}/articles/{}", source.meta.date, pick.id)
);
let anonymous = get(format!("/articles/{archived_id}"), None).await;
assert!(anonymous.status().is_redirection());
assert!(
anonymous.headers()[header::LOCATION]
.to_str()
.unwrap()
.starts_with("/login")
);
}
#[tokio::test]
async fn fallback_full_issue_recovers_colophon_world_and_behind() {
let (_dir, db, source) = seeded_issue(false).await;