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:
1 parent
bf6a8d0129
commit
03f0a454be
17 files changed
+788
-30
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user