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:
2026-10-11 21:34:31 +00:00
co-authored by Claude Opus 5.5
parent bf6a8d0129
commit 03f0a454be
17 changed files with 788 additions and 30 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ affinity_full = 40
slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict
semantic_min_words = 300 semantic_min_words = 300
exploration_slots = 5 exploration_slots = 5
embedding_retention_days = 120 # `features prune`: unrated, unpublished vectors embedding_retention_days = 0 # `features prune`: unrated, unpublished vectors; 0 = keep forever
telemetry_retention_days = 180 # `features prune`: candidate_runs and article_assessments telemetry_retention_days = 180 # `features prune`: candidate_runs and article_assessments
[curation.ranking.quotas] [curation.ranking.quotas]
+12
View File
@@ -0,0 +1,12 @@
-- Related articles under each web issue article: the nearest embedded articles
-- across the whole archive, computed when the issue is published (and by
-- `features related` for older issues). Stored rather than computed per page
-- view because finding them is a scan of every cached embedding.
CREATE TABLE issue_related (
issue_date TEXT NOT NULL REFERENCES issues(date) ON DELETE CASCADE,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
position INTEGER NOT NULL, -- 0 = closest
related_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
cosine REAL NOT NULL,
PRIMARY KEY (issue_date, article_id, position)
);
+4 -2
View File
@@ -490,6 +490,8 @@ pub struct RankingConfig {
pub slop_author_penalty: f64, pub slop_author_penalty: f64,
pub semantic_min_words: i64, pub semantic_min_words: i64,
pub exploration_slots: usize, pub exploration_slots: usize,
/// Days before `features prune` drops unrated, unpublished vectors; 0 keeps
/// them forever (the default, so related articles span the whole archive).
pub embedding_retention_days: i64, pub embedding_retention_days: i64,
pub telemetry_retention_days: i64, pub telemetry_retention_days: i64,
pub quotas: RankingQuotas, pub quotas: RankingQuotas,
@@ -517,7 +519,7 @@ impl Default for RankingConfig {
slop_author_penalty: 0.75, slop_author_penalty: 0.75,
semantic_min_words: 300, semantic_min_words: 300,
exploration_slots: 5, exploration_slots: 5,
embedding_retention_days: 120, embedding_retention_days: 0,
telemetry_retention_days: 180, telemetry_retention_days: 180,
quotas: RankingQuotas::default(), quotas: RankingQuotas::default(),
weights: RankingWeights::default(), weights: RankingWeights::default(),
@@ -2104,7 +2106,7 @@ mod tests {
assert_eq!(ranking.weights.utility.knn, 0.10); assert_eq!(ranking.weights.utility.knn, 0.10);
assert_eq!(ranking.weights.utility.affinity, 0.05); assert_eq!(ranking.weights.utility.affinity, 0.05);
assert_eq!(ranking.diversity.per_cluster_cap, 2); assert_eq!(ranking.diversity.per_cluster_cap, 2);
assert_eq!(ranking.embedding_retention_days, 120); assert_eq!(ranking.embedding_retention_days, 0);
assert_eq!(ranking.telemetry_retention_days, 180); assert_eq!(ranking.telemetry_retention_days, 180);
cfg.validate().unwrap(); cfg.validate().unwrap();
+4 -4
View File
@@ -444,10 +444,10 @@ pub fn dot(left: &[f32], right: &[f32]) -> Result<f64, EmbeddingError> {
/// The `limit` closest compatible cached article vectors, highest cosine first. /// The `limit` closest compatible cached article vectors, highest cosine first.
/// ///
/// A brute-force scan: the cache holds at most a few thousand rows after /// A brute-force scan of every compatible row: fine for the admin dashboard,
/// pruning, so one statement plus a sort is cheap. The caller supplies the /// too slow for reader pages (see [`crate::related`], which stores its
/// target row's model, dimension and decoded vector so pages that already /// results). The caller supplies the target row's model, dimension and decoded
/// load that row do not query it a second time. Malformed candidate blobs are /// vector so pages that already load that row do not query it a second time. Malformed candidate blobs are
/// ignored like malformed entries in the normal cache loader. /// ignored like malformed entries in the normal cache loader.
pub async fn nearest_articles( pub async fn nearest_articles(
db: &Db, db: &Db,
+49 -9
View File
@@ -1058,7 +1058,7 @@ pub fn render_stats_text(data: &StatsData) -> String {
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Pruned { pub struct Pruned {
/// `article_embeddings` of unrated, unpublished articles past /// `article_embeddings` of unrated, unpublished articles past
/// `embedding_retention_days`. /// `embedding_retention_days`; always 0 when that is 0 (keep forever).
pub embeddings: u64, pub embeddings: u64,
/// `candidate_runs` rows of runs past `telemetry_retention_days`. /// `candidate_runs` rows of runs past `telemetry_retention_days`.
pub telemetry: u64, pub telemetry: u64,
@@ -1067,7 +1067,8 @@ pub struct Pruned {
} }
/// Delete `article_embeddings` for articles neither rated nor published that /// Delete `article_embeddings` for articles neither rated nor published that
/// are older than `embedding_retention_days`, `candidate_runs` rows whose run /// are older than `embedding_retention_days` (0 or less keeps every vector, so
/// related articles can reach the whole archive), `candidate_runs` rows whose run
/// started more than `telemetry_retention_days` ago, and `article_assessments` /// started more than `telemetry_retention_days` ago, and `article_assessments`
/// older than the same window (§7.1, §7.4). Runs from `features prune` and /// older than the same window (§7.1, §7.4). Runs from `features prune` and
/// once per `generate` after publishing. /// once per `generate` after publishing.
@@ -1081,8 +1082,11 @@ pub async fn prune(
now.checked_sub(jiff::Span::new().hours(days.max(0).saturating_mul(24))) now.checked_sub(jiff::Span::new().hours(days.max(0).saturating_mul(24)))
.unwrap_or(Timestamp::UNIX_EPOCH) .unwrap_or(Timestamp::UNIX_EPOCH)
}; };
let embeddings = sqlx::query( let embeddings = if embedding_retention_days <= 0 {
"DELETE FROM article_embeddings 0
} else {
sqlx::query(
"DELETE FROM article_embeddings
WHERE article_id IN ( WHERE article_id IN (
SELECT ae.article_id SELECT ae.article_id
FROM article_embeddings ae JOIN articles a ON a.id = ae.article_id FROM article_embeddings ae JOIN articles a ON a.id = ae.article_id
@@ -1090,11 +1094,12 @@ pub async fn prune(
AND NOT EXISTS (SELECT 1 FROM rating_events re WHERE re.article_id = ae.article_id) AND NOT EXISTS (SELECT 1 FROM rating_events re WHERE re.article_id = ae.article_id)
AND NOT EXISTS (SELECT 1 FROM issue_articles ia WHERE ia.article_id = ae.article_id) AND NOT EXISTS (SELECT 1 FROM issue_articles ia WHERE ia.article_id = ae.article_id)
)", )",
) )
.bind(fmt_ts(cutoff(embedding_retention_days))) .bind(fmt_ts(cutoff(embedding_retention_days)))
.execute(db.pool()) .execute(db.pool())
.await? .await?
.rows_affected(); .rows_affected()
};
let telemetry = sqlx::query( let telemetry = sqlx::query(
"DELETE FROM candidate_runs "DELETE FROM candidate_runs
@@ -1942,4 +1947,39 @@ mean generation time: n/a (0 runs)
// A second pass finds nothing left to remove. // A second pass finds nothing left to remove.
assert_eq!(prune(&db, 120, 180, now).await.unwrap(), Pruned::default()); assert_eq!(prune(&db, 120, 180, now).await.unwrap(), Pruned::default());
} }
#[tokio::test]
async fn zero_embedding_retention_keeps_every_vector() {
let (_dir, db) = db_with_articles(&[1, 2]).await;
let now = Timestamp::now();
let old = fmt_ts(now - jiff::Span::new().hours(2000 * 24));
sqlx::query("UPDATE articles SET first_seen = ?")
.bind(&old)
.execute(db.pool())
.await
.unwrap();
let blob = encode_blob(&[0.5, 0.5]).unwrap();
for id in 1..=2 {
sqlx::query(
"INSERT INTO article_embeddings
(article_id, model, dimension, input_hash, embedding, created_at)
VALUES (?, 'voyage-4-lite', 2, 'h', ?, ?)",
)
.bind(id)
.bind(&blob)
.bind(&old)
.execute(db.pool())
.await
.unwrap();
}
// 0 must not mean "a cutoff of now", which would delete everything.
let pruned = prune(&db, 0, 180, now).await.unwrap();
assert_eq!(pruned.embeddings, 0);
let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM article_embeddings")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(remaining, 2);
}
} }
+13 -2
View File
@@ -48,11 +48,13 @@ pub enum Job {
ImportRatings, ImportRatings,
/// `interests-categorize` → file uncategorized standing interests. /// `interests-categorize` → file uncategorized standing interests.
InterestsCategorize, InterestsCategorize,
/// `related-refresh` → `features related` (every issue).
RelatedRefresh,
} }
impl Job { impl Job {
/// The catalogue in the order the Jobs page lists it. /// The catalogue in the order the Jobs page lists it.
pub const CATALOGUE: [Job; 8] = [ pub const CATALOGUE: [Job; 9] = [
Job::Generate { date: None }, Job::Generate { date: None },
Job::DryRun, Job::DryRun,
Job::ProfileRebuild, Job::ProfileRebuild,
@@ -61,6 +63,7 @@ impl Job {
Job::FeaturesPrune, Job::FeaturesPrune,
Job::ImportRatings, Job::ImportRatings,
Job::InterestsCategorize, Job::InterestsCategorize,
Job::RelatedRefresh,
]; ];
/// `^[a-z0-9-]+$`: the only characters a job (and so a unit instance) name /// `^[a-z0-9-]+$`: the only characters a job (and so a unit instance) name
@@ -88,6 +91,7 @@ impl Job {
"features-prune" => Some(Job::FeaturesPrune), "features-prune" => Some(Job::FeaturesPrune),
"import-ratings" => Some(Job::ImportRatings), "import-ratings" => Some(Job::ImportRatings),
"interests-categorize" => Some(Job::InterestsCategorize), "interests-categorize" => Some(Job::InterestsCategorize),
"related-refresh" => Some(Job::RelatedRefresh),
_ => { _ => {
let (rebuild, date) = match name.strip_prefix("generate-") { let (rebuild, date) = match name.strip_prefix("generate-") {
Some(date) => (false, date), Some(date) => (false, date),
@@ -116,6 +120,7 @@ impl Job {
Job::FeaturesPrune => "features-prune".into(), Job::FeaturesPrune => "features-prune".into(),
Job::ImportRatings => "import-ratings".into(), Job::ImportRatings => "import-ratings".into(),
Job::InterestsCategorize => "interests-categorize".into(), Job::InterestsCategorize => "interests-categorize".into(),
Job::RelatedRefresh => "related-refresh".into(),
} }
} }
@@ -150,6 +155,9 @@ impl Job {
Job::InterestsCategorize => { Job::InterestsCategorize => {
"File uncategorized interests under categories with the bulk model, creating new ones only when needed." "File uncategorized interests under categories with the bulk model, creating new ones only when needed."
} }
Job::RelatedRefresh => {
"Recompute the related articles listed under every issue's web articles."
}
} }
} }
@@ -162,7 +170,10 @@ impl Job {
Job::ProfileRebuild => Some("profile rebuild"), Job::ProfileRebuild => Some("profile rebuild"),
Job::FeaturesBackfill => Some("features backfill"), Job::FeaturesBackfill => Some("features backfill"),
Job::BackfillSocial => Some("backfill-social"), Job::BackfillSocial => Some("backfill-social"),
Job::FeaturesPrune | Job::ImportRatings | Job::InterestsCategorize => None, Job::FeaturesPrune
| Job::ImportRatings
| Job::InterestsCategorize
| Job::RelatedRefresh => None,
} }
} }
+1
View File
@@ -35,6 +35,7 @@ pub mod miniflux;
pub mod pipeline; pub mod pipeline;
pub mod publish; pub mod publish;
pub mod rate; pub mod rate;
pub mod related;
pub mod report; pub mod report;
pub mod server; pub mod server;
pub mod social; pub mod social;
+50 -10
View File
@@ -80,8 +80,8 @@ enum JobCommand {
/// Run one catalogue job in-process and record it in the `jobs` table. /// Run one catalogue job in-process and record it in the `jobs` table.
Run { Run {
/// `generate`, `generate-YYYY-MM-DD`, `dry-run`, `profile-rebuild`, /// `generate`, `generate-YYYY-MM-DD`, `dry-run`, `profile-rebuild`,
/// `features-backfill`, `backfill-social`, `features-prune`, `import-ratings` /// `features-backfill`, `backfill-social`, `features-prune`, `import-ratings`,
/// or `interests-categorize`. /// `interests-categorize` or `related-refresh`.
name: String, name: String,
}, },
} }
@@ -339,6 +339,15 @@ enum FeaturesCommand {
Backfill(BackfillArgs), Backfill(BackfillArgs),
/// Drop stale embeddings, old candidate telemetry and old assessments per the retention config. /// Drop stale embeddings, old candidate telemetry and old assessments per the retention config.
Prune, Prune,
/// Recompute the related articles under each issue's web articles.
Related(RelatedArgs),
}
#[derive(Debug, clap::Args)]
struct RelatedArgs {
/// Only this issue (YYYY-MM-DD); every issue when omitted.
#[arg(long)]
date: Option<jiff::civil::Date>,
} }
#[derive(Debug, clap::Args)] #[derive(Debug, clap::Args)]
@@ -512,6 +521,7 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
| Command::Explain(_) | Command::Explain(_)
| Command::Stats(_) | Command::Stats(_)
| Command::Features(FeaturesCommand::Prune) | Command::Features(FeaturesCommand::Prune)
| Command::Features(FeaturesCommand::Related(_))
| Command::Db(_) | Command::Db(_)
| Command::Config(_) | Command::Config(_)
| Command::Users(_) | Command::Users(_)
@@ -1062,7 +1072,7 @@ async fn cmd_explain(db: &Db, args: ExplainArgs) -> Result<()> {
Ok(()) Ok(())
} }
/// `features backfill` / `features prune`; returns the final summary line /// `features backfill` / `features prune` / `features related`; returns the final summary line
/// (progress lines are printed as they happen). /// (progress lines are printed as they happen).
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> { async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> {
match command { match command {
@@ -1120,13 +1130,25 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
jiff::Timestamp::now(), jiff::Timestamp::now(),
) )
.await?; .await?;
let embeddings = if ranking.embedding_retention_days <= 0 {
"kept every embedding (retention 0)".to_string()
} else {
format!(
"pruned {} embeddings older than {} days",
pruned.embeddings, ranking.embedding_retention_days
)
};
Ok(format!( Ok(format!(
"pruned {} embeddings older than {} days, {} candidate rows and {} assessments older than {} days", "{embeddings}, {} candidate rows and {} assessments older than {} days",
pruned.embeddings, pruned.telemetry, pruned.assessments, ranking.telemetry_retention_days
ranking.embedding_retention_days, ))
pruned.telemetry, }
pruned.assessments, FeaturesCommand::Related(args) => {
ranking.telemetry_retention_days let dates: Vec<_> = args.date.into_iter().collect();
let refreshed = daily_epub::related::refresh(db, &config.voyage, &dates).await?;
Ok(format!(
"stored {} related links across {} issues",
refreshed.links, refreshed.issues
)) ))
} }
} }
@@ -1226,6 +1248,15 @@ async fn run_job(config: &Config, db: &Db, job: &jobs::Job) -> Result<(String, O
)), )),
jobs::Job::ImportRatings => Ok((imports::run(config, db).await?, None)), jobs::Job::ImportRatings => Ok((imports::run(config, db).await?, None)),
jobs::Job::InterestsCategorize => Ok((interests::categorize(config, db).await?, None)), jobs::Job::InterestsCategorize => Ok((interests::categorize(config, db).await?, None)),
jobs::Job::RelatedRefresh => Ok((
cmd_features(
config,
db,
FeaturesCommand::Related(RelatedArgs { date: None }),
)
.await?,
None,
)),
} }
} }
@@ -1494,7 +1525,7 @@ mod tests {
row.message row.message
.as_deref() .as_deref()
.unwrap_or_default() .unwrap_or_default()
.starts_with("pruned 0 embeddings"), .starts_with("kept every embedding (retention 0)"),
"{row:?}" "{row:?}"
); );
assert_eq!(row.run_id, None); assert_eq!(row.run_id, None);
@@ -1684,6 +1715,15 @@ mod tests {
.command, .command,
Command::Features(FeaturesCommand::Prune) Command::Features(FeaturesCommand::Prune)
)); ));
match Cli::try_parse_from(["daily-epub", "features", "related", "--date", "2026-09-01"])
.unwrap()
.command
{
Command::Features(FeaturesCommand::Related(args)) => {
assert_eq!(args.date, Some("2026-09-01".parse().unwrap()));
}
other => panic!("expected features related, got {other:?}"),
}
} }
#[tokio::test] #[tokio::test]
+10
View File
@@ -268,6 +268,7 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
db.finish_run(run_id, &report).await?; db.finish_run(run_id, &report).await?;
if stages.published.is_some() { if stages.published.is_some() {
prune_retention(config, db).await; prune_retention(config, db).await;
refresh_related(config, db, date).await;
} }
// The issue row is written before the report is costed, so stamp the finished // The issue row is written before the report is costed, so stamp the finished
// report onto it now (the paths are preserved by `COALESCE`, §3.13). // report onto it now (the paths are preserved by `COALESCE`, §3.13).
@@ -321,6 +322,15 @@ async fn prune_retention(config: &Config, db: &Db) {
} }
} }
/// Store the issue's related-article links (best effort: a failure only
/// leaves the web article pages without them until `features related`).
async fn refresh_related(config: &Config, db: &Db, date: Date) {
match crate::related::refresh(db, &config.voyage, &[date]).await {
Ok(refreshed) => tracing::info!(links = refreshed.links, "related articles stored"),
Err(error) => tracing::warn!(%error, "related articles failed; continuing"),
}
}
/// What [`run_stages`] hands back; [`generate`] pairs it with the costed report. /// What [`run_stages`] hands back; [`generate`] pairs it with the costed report.
#[derive(Debug)] #[derive(Debug)]
struct StageOutput { struct StageOutput {
+412
View File
@@ -0,0 +1,412 @@
//! Related articles under each web issue article.
//!
//! For every pick, the nearest embedded articles across the whole archive
//! (`article_embeddings`, kept forever by default). Finding them is a scan of
//! every cached vector, so it runs once when an issue is published and once
//! per issue from `features related`, and the result lives in `issue_related`;
//! reader pages only read that table.
use std::collections::{HashMap, HashSet};
use anyhow::Result;
use jiff::civil::Date;
use sqlx::Row as _;
use crate::config::VoyageConfig;
use crate::curate::embedding::{decode_blob, dot, load_article_embeddings};
use crate::db::Db;
use crate::types::ArticleId;
/// Links shown under each article.
pub const PER_ARTICLE: usize = 5;
/// Below this cosine a neighbour is noise, not a related article (the same
/// cut the curation prompts use for "closest rated").
pub 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`].
const KEEP: usize = PER_ARTICLE * 4;
/// One stored related link.
#[derive(Debug, Clone, PartialEq)]
pub struct Related {
pub article_id: ArticleId,
pub cosine: f64,
}
/// What [`refresh`] wrote.
#[derive(Debug, Default, PartialEq)]
pub struct Refreshed {
pub issues: usize,
pub links: usize,
}
/// Recompute `issue_related` for the given issues (every issue when `dates` is
/// 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).
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")
.fetch_all(db.pool())
.await?
.into_iter()
.filter_map(|raw| raw.parse().ok())
.collect()
} else {
dates.to_vec()
};
// Every pick of every requested issue, with its title 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
JOIN articles a ON a.id = ia.article_id
WHERE ia.issue_date = ? ORDER BY ia.section, ia.position",
)
.bind(date.to_string())
.fetch_all(db.pool())
.await?;
groups.push(Group {
date: *date,
members: rows.iter().map(|row| row.get("article_id")).collect(),
picks: rows
.iter()
.map(|row| Target {
article_id: row.get("article_id"),
title_key: title_key(row.get::<Option<String>, _>("title").as_deref()),
vector: Vec::new(),
best: Vec::new(),
})
.collect(),
});
}
let ids: Vec<ArticleId> = groups
.iter()
.flat_map(|group| group.picks.iter().map(|pick| pick.article_id))
.collect();
let mut vectors = load_article_embeddings(db, voyage, &ids).await?;
for group in &mut groups {
// A pick without a cached vector simply gets no related list.
group
.picks
.retain_mut(|pick| match vectors.remove(&pick.article_id) {
Some(vector) => {
pick.vector = vector;
true
}
None => false,
});
}
let mut titles: HashMap<ArticleId, String> = HashMap::new();
let mut after = i64::MIN;
loop {
let rows = sqlx::query(
"SELECT ae.article_id, ae.embedding, a.title
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 > ?
ORDER BY ae.article_id LIMIT ?",
)
.bind(&voyage.model)
.bind(voyage.output_dimension as i64)
.bind(after)
.bind(SCAN_PAGE)
.fetch_all(db.pool())
.await?;
let Some(last) = rows.last() else { break };
after = last.get("article_id");
for row in &rows {
let candidate: ArticleId = row.get("article_id");
let vector = match decode_blob(
&row.get::<Vec<u8>, _>("embedding"),
voyage.output_dimension,
) {
Ok(vector) => vector,
Err(error) => {
tracing::warn!(article_id = candidate, %error, "ignoring a malformed embedding");
continue;
}
};
let mut kept = false;
for group in &mut groups {
if group.members.contains(&candidate) {
continue;
}
for pick in &mut group.picks {
let cosine = dot(&pick.vector, &vector)?;
if cosine.is_finite() && cosine >= MIN_COSINE {
pick.best.push((candidate, cosine));
kept = true;
}
}
}
if kept {
titles.insert(
candidate,
title_key(row.get::<Option<String>, _>("title").as_deref()),
);
}
}
for group in &mut groups {
for pick in &mut group.picks {
rank(&mut pick.best);
pick.best.truncate(KEEP);
}
}
}
let mut refreshed = Refreshed::default();
let mut tx = db.pool().begin().await?;
for group in &groups {
let date = group.date.to_string();
sqlx::query("DELETE FROM issue_related WHERE issue_date = ?")
.bind(&date)
.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 chosen = pick
.best
.iter()
.filter(|(id, _)| {
let key = titles.get(id).map_or("", String::as_str);
key.is_empty() || seen.insert(key)
})
.take(PER_ARTICLE);
for (position, (related_id, cosine)) in chosen.enumerate() {
sqlx::query(
"INSERT INTO issue_related (issue_date, article_id, position, related_id, cosine)
VALUES (?, ?, ?, ?, ?)",
)
.bind(&date)
.bind(pick.article_id)
.bind(position as i64)
.bind(related_id)
.bind(cosine)
.execute(&mut *tx)
.await?;
refreshed.links += 1;
}
}
refreshed.issues += 1;
}
tx.commit().await?;
Ok(refreshed)
}
/// The stored related links for one pick, closest first.
pub async fn for_article(db: &Db, date: Date, article_id: ArticleId) -> Result<Vec<Related>> {
let rows = sqlx::query(
"SELECT related_id, cosine FROM issue_related
WHERE issue_date = ? AND article_id = ? ORDER BY position",
)
.bind(date.to_string())
.bind(article_id)
.fetch_all(db.pool())
.await?;
Ok(rows
.iter()
.map(|row| Related {
article_id: row.get("related_id"),
cosine: row.get("cosine"),
})
.collect())
}
/// The most recent issue each of `ids` was published in, for linking a related
/// article to its issue page rather than the standalone one.
pub async fn latest_issues(db: &Db, ids: &[ArticleId]) -> Result<HashMap<ArticleId, Date>> {
let mut issues = HashMap::new();
for id in ids {
let date: Option<String> =
sqlx::query_scalar("SELECT MAX(issue_date) FROM issue_articles WHERE article_id = ?")
.bind(id)
.fetch_one(db.pool())
.await?;
if let Some(date) = date.and_then(|raw| raw.parse().ok()) {
issues.insert(*id, date);
}
}
Ok(issues)
}
struct Group {
date: Date,
/// Every pick of the issue, embedded or not: none of them is "related".
members: HashSet<ArticleId>,
picks: Vec<Target>,
}
struct Target {
article_id: ArticleId,
title_key: String,
vector: Vec<f32>,
best: Vec<(ArticleId, f64)>,
}
/// Highest cosine first; ties by id so reruns write the same rows.
fn rank(best: &mut [(ArticleId, f64)]) {
best.sort_by(|left, right| {
right
.1
.total_cmp(&left.1)
.then_with(|| left.0.cmp(&right.0))
});
}
/// 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()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::curate::embedding::encode_blob;
fn voyage() -> VoyageConfig {
VoyageConfig {
model: "voyage-4-lite".into(),
output_dimension: 2,
..VoyageConfig::default()
}
}
async fn harness() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("related.db"))
.await
.unwrap();
(dir, db)
}
async fn article(db: &Db, id: ArticleId, title: &str, vector: &[f32], excerpt_only: bool) {
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, excerpt_only, first_seen)
VALUES (?, ?, ?, ?, '2026-08-15T00:00:00Z')",
)
.bind(id)
.bind(format!("https://example.com/{id}"))
.bind(title)
.bind(excerpt_only)
.execute(db.pool())
.await
.unwrap();
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt();
let unit: Vec<f32> = vector.iter().map(|v| v / norm).collect();
sqlx::query(
"INSERT INTO article_embeddings
(article_id, model, dimension, input_hash, embedding, created_at)
VALUES (?, 'voyage-4-lite', 2, 'h', ?, '2026-08-15T00:00:00Z')",
)
.bind(id)
.bind(encode_blob(&unit).unwrap())
.execute(db.pool())
.await
.unwrap();
}
async fn issue(db: &Db, date: &str, picks: &[ArticleId]) {
sqlx::query("INSERT INTO issues (date, issue_number, generated_at) VALUES (?, 1, ?)")
.bind(date)
.bind("2026-09-01T00:00:00Z")
.execute(db.pool())
.await
.unwrap();
for (position, id) in picks.iter().enumerate() {
sqlx::query(
"INSERT INTO issue_articles (issue_date, article_id, section, position)
VALUES (?, ?, 'Top Stories', ?)",
)
.bind(date)
.bind(id)
.bind(position as i64)
.execute(db.pool())
.await
.unwrap();
}
}
fn day(raw: &str) -> Date {
raw.parse().unwrap()
}
#[tokio::test]
async fn links_skip_the_issue_excerpts_duplicates_and_far_articles() {
let (_dir, db) = harness().await;
article(&db, 1, "Rust borrow checker", &[1.0, 0.0], false).await;
article(&db, 2, "Same issue neighbour", &[1.0, 0.01], false).await;
article(&db, 3, "Lifetimes explained", &[1.0, 0.1], false).await;
article(&db, 4, "Paywalled", &[1.0, 0.05], true).await;
article(&db, 5, "Rust Borrow-Checker!", &[1.0, 0.02], false).await;
article(&db, 6, "Ownership deep dive", &[1.0, 0.3], false).await;
article(&db, 7, "Ownership: deep dive", &[1.0, 0.31], false).await;
article(&db, 8, "Gardening", &[0.0, 1.0], false).await;
issue(&db, "2026-09-01", &[1, 2]).await;
let refreshed = refresh(&db, &voyage(), &[day("2026-09-01")]).await.unwrap();
let links = for_article(&db, day("2026-09-01"), 1).await.unwrap();
let ids: Vec<ArticleId> = links.iter().map(|link| link.article_id).collect();
assert_eq!(ids, vec![3, 6], "closest first, one copy of each story");
assert!(links[0].cosine > links[1].cosine);
// Article 2's neighbours: 3, 6 (5 shares 1's title but not 2's).
let two: Vec<ArticleId> = for_article(&db, day("2026-09-01"), 2)
.await
.unwrap()
.iter()
.map(|link| link.article_id)
.collect();
assert_eq!(two, vec![5, 3, 6]);
assert_eq!(
refreshed,
Refreshed {
issues: 1,
links: 5
}
);
}
#[tokio::test]
async fn refreshing_every_issue_replaces_rows_and_links_earlier_picks() {
let (_dir, db) = harness().await;
article(&db, 1, "One", &[1.0, 0.0], false).await;
article(&db, 2, "Two", &[1.0, 0.1], false).await;
article(&db, 3, "Three", &[1.0, 0.3], false).await;
issue(&db, "2026-09-01", &[1]).await;
issue(&db, "2026-09-02", &[2]).await;
assert_eq!(refresh(&db, &voyage(), &[]).await.unwrap().issues, 2);
// A rerun rewrites rather than duplicates.
refresh(&db, &voyage(), &[]).await.unwrap();
let ids: Vec<ArticleId> = for_article(&db, day("2026-09-02"), 2)
.await
.unwrap()
.iter()
.map(|link| link.article_id)
.collect();
assert_eq!(ids, vec![1, 3]);
let issues = latest_issues(&db, &[1, 3]).await.unwrap();
assert_eq!(issues.get(&1), Some(&day("2026-09-01")));
assert_eq!(issues.get(&3), None);
}
#[test]
fn title_keys_ignore_case_and_punctuation() {
assert_eq!(title_key(Some("Foo: Bar — Baz!")), "foobarbaz");
assert_eq!(title_key(None), "");
}
}
+1 -1
View File
@@ -353,7 +353,7 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
("curation.ranking.affinity_full", "Interest-attributable ratings at which affinity reaches full weight. Must be > affinity_floor."), ("curation.ranking.affinity_full", "Interest-attributable ratings at which affinity reaches full weight. Must be > affinity_floor."),
("curation.ranking.semantic_min_words", "Bodies shorter than this are not embedded."), ("curation.ranking.semantic_min_words", "Bodies shorter than this are not embedded."),
("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."), ("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."),
("curation.ranking.embedding_retention_days", "features prune: unrated, unpublished vectors older than this are deleted."), ("curation.ranking.embedding_retention_days", "features prune: unrated, unpublished vectors older than this are deleted; 0 keeps them forever."),
("curation.ranking.telemetry_retention_days", "features prune: candidate_runs and article_assessments older than this are deleted."), ("curation.ranking.telemetry_retention_days", "features prune: candidate_runs and article_assessments older than this are deleted."),
("curation.ranking.quotas.triage", "Deep-set slots filled by triage score."), ("curation.ranking.quotas.triage", "Deep-set slots filled by triage score."),
("curation.ranking.quotas.interest", "Deep-set slots filled by interest similarity."), ("curation.ranking.quotas.interest", "Deep-set slots filled by interest similarity."),
+221
View File
@@ -960,6 +960,36 @@ struct ArticleTemplate {
previous: Option<ArticleLink>, previous: Option<ArticleLink>,
next: Option<ArticleLink>, next: Option<ArticleLink>,
issue_href: String, 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)] #[derive(Template)]
@@ -1114,6 +1144,7 @@ pub async fn article(
let is_admin = viewer.role == crate::web::users::Role::Admin; let is_admin = viewer.role == crate::web::users::Role::Admin;
let config = state.config(); let config = state.config();
let active_nav = if view.is_latest { "latest" } else { "archive" }; 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); let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
page.flash = take_flash(&session).await?; page.flash = take_flash(&session).await?;
Ok(Html(ArticleTemplate { Ok(Html(ArticleTemplate {
@@ -1150,10 +1181,104 @@ pub async fn article(
previous, previous,
next, next,
issue_href: issue_href(date), issue_href: issue_href(date),
related,
}) })
.into_response()) .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( pub async fn world(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthSession, auth: AuthSession,
@@ -1335,6 +1460,10 @@ fn article_href(date: Date, article_id: ArticleId) -> String {
format!("/issues/{date}/articles/{article_id}") format!("/issues/{date}/articles/{article_id}")
} }
fn standalone_href(article_id: ArticleId) -> String {
format!("/articles/{article_id}")
}
fn issue_href(date: Date) -> String { fn issue_href(date: Date) -> String {
format!("/issues/{date}") format!("/issues/{date}")
} }
@@ -2198,6 +2327,98 @@ mod tests {
assert!(!reader.contains(&feed_href)); 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] #[tokio::test]
async fn fallback_full_issue_recovers_colophon_world_and_behind() { async fn fallback_full_issue_recovers_colophon_world_and_behind() {
let (_dir, db, source) = seeded_issue(false).await; let (_dir, db, source) = seeded_issue(false).await;
+1
View File
@@ -681,6 +681,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
.route_layer(from_fn(session::require_password_change)); .route_layer(from_fn(session::require_password_change));
let full_issues = axum::Router::new() let full_issues = axum::Router::new()
.route("/issues/{date}/articles/{article_id}", get(issue::article)) .route("/issues/{date}/articles/{article_id}", get(issue::article))
.route("/articles/{article_id}", get(issue::standalone))
.route("/issues/{date}/world", get(issue::world)) .route("/issues/{date}/world", get(issue::world))
.route("/issues/{date}/behind", get(issue::behind)) .route("/issues/{date}/behind", get(issue::behind))
.route("/issues/{date}/read", get(issue::read)) .route("/issues/{date}/read", get(issue::read))
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{% if !related.is_empty() %}<section class="mt-12" aria-labelledby="related-heading"><h2 id="related-heading" class="reader-section-heading">Related</h2><ul class="m-0 list-none p-0">{% for item in related %}<li class="border-b border-rule py-5"><h3 class="font-serif text-xl font-semibold leading-[1.15] tracking-[-0.01em]"><a class="text-ink no-underline hover:text-accent" href="{{ item.href }}">{{ item.title }}</a></h3><p class="mt-1.5 font-sans text-sm text-muted">{% match item.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{% let source = item.source %}{% include "_source.html" %} · {{ item.reading_minutes }} min read · {{ item.date }}</p></li>{% endfor %}</ul></section>{% endif %}
+1
View File
@@ -3,6 +3,7 @@
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{% include "_source.html" %} · {{ meta_line }}</p>{% include "_understanding.html" %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header> <header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{% include "_source.html" %} · {{ meta_line }}</p>{% include "_understanding.html" %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div> <div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %} {% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
{% include "_related.html" %}
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer> <footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
</article> </article>
</div>{% endblock %} </div>{% endblock %}
@@ -0,0 +1,6 @@
{% extends "layout.html" %}{% block ears %}Article{% endblock %}{% block content %}
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{% include "_source.html" %} · {{ meta_line }}</p>{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
<footer class="mt-12 border-t border-rule pt-6"><p class="flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p></footer>
</article>{% endblock %}