From 6c2d74c734d60ec74d9412f94e7f75cfb9008809 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 5 Sep 2026 04:08:06 +0000 Subject: [PATCH] Stop routine excerpt fallbacks from degrading every run Every issue since 2026-08-15 landed as `degraded`, because the extraction stage warned on any fetch failure at all and a few always fail: the worst of the 21 published days was 15% (33 of 220 articles), the median under 11%. A status every run carries says nothing, so warn only past a 30% share -- roughly twice the worst day seen -- and log the rest. The exact count was already in `counts.excerpt_only` either way. `rebuild()` also stored the bumped profile version before reading the interests OPML, so a rebuild that failed on a missing file would mark the profile fresh for another week having never rewritten its text. Read the prompt inputs first, ahead of both the model call and the writes, so a failure stays due and costs nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0164rQrMUZkf7uV1VGYuCFy4 --- src/curate/profile/mod.rs | 52 ++++++++++++++++++++++++++++++++++----- src/pipeline.rs | 26 +++++++++++++++++--- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/curate/profile/mod.rs b/src/curate/profile/mod.rs index f2a7952..38eda2c 100644 --- a/src/curate/profile/mod.rs +++ b/src/curate/profile/mod.rs @@ -432,6 +432,11 @@ pub async fn rebuild( profile_path: &Path, verdict_limit: usize, ) -> anyhow::Result { + // Read the prompt inputs first: a rebuild that dies on a missing OPML must + // stay due and must not have spent a model call getting there. + let opml = parse_interests(opml_path)?; + let profile_file = load_profile(profile_path)?; + let interests = union_interests(opml, profile_file.interests.clone()); let ratings = db.current_ratings(RATINGS_LOOKBACK_DAYS).await?; let previous = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default(); let learned = if ratings.is_empty() { @@ -464,21 +469,17 @@ pub async fn rebuild( let built_at = Timestamp::now(); store_version(db, next_version, built_at).await?; - let opml = parse_interests(opml_path)?; - let profile_file = load_profile(profile_path)?; - let interests = union_interests(opml, profile_file.interests.clone()); - let current = db.current_ratings(RATINGS_LOOKBACK_DAYS).await?; let profile = TasteProfile { text: build( &profile_file.body, &interests, &learned, - ¤t, + &ratings, verdict_limit, ), version: next_version, built_at, - verdicts: current.len().min(verdict_limit), + verdicts: ratings.len().min(verdict_limit), }; db.kv_set(KV_TASTE_PROFILE, &profile.text).await?; tracing::info!( @@ -686,4 +687,43 @@ mod tests { assert_eq!(backend.calls(), 1); assert!(backend.prompts()[0].user.contains("LOVED | A deep report")); } + + #[tokio::test] + async fn a_failed_rebuild_leaves_the_version_alone_and_spends_nothing() { + use std::sync::Arc; + + use super::super::llm::{MockBackend, UsageMeter}; + use crate::config::ProviderConfig; + + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("profile.db")) + .await + .unwrap(); + let opml = dir.path().join("interests.opml"); + let profile_path = dir.path().join("profile.md"); + std::fs::write(&opml, r#""#).unwrap(); + std::fs::write(&profile_path, "# Reader profile\n\nLikes depth.\n").unwrap(); + let initial = load_or_build(&db, &opml, &profile_path, 60).await.unwrap(); + assert_eq!(initial.version, 1); + + // The OPML goes missing the way a relative path does under a service + // whose working directory is not the checkout. + std::fs::remove_file(&opml).unwrap(); + + let backend = Arc::new(MockBackend::new()); + let llm = LlmClient::with_backend( + "deepseek-v4-flash", + initial.text, + UsageMeter::for_provider(&ProviderConfig::deepseek()), + backend.clone(), + ); + let error = rebuild(&db, &llm, &opml, &profile_path, 60) + .await + .expect_err("a missing OPML fails the rebuild"); + assert!(format!("{error:#}").contains("reading the interests OPML")); + + // Still version 1, so the profile stays stale and the rebuild is retried. + assert_eq!(stored_version(&db).await.unwrap().unwrap().0, 1); + assert_eq!(backend.calls(), 0); + } } diff --git a/src/pipeline.rs b/src/pipeline.rs index 55e2f5c..297fe72 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -346,6 +346,11 @@ struct StageContext<'a> { rescore: bool, } +/// Share of the day's articles that must fall back to a feed excerpt before the +/// run is degraded over it. Single-digit percentages are routine; the exact count +/// is always in `counts.excerpt_only`. +const EXCERPT_FALLBACK_WARN_SHARE: f64 = 0.30; + async fn run_stages( ctx: &StageContext<'_>, window_start: Timestamp, @@ -412,11 +417,24 @@ async fn run_stages( let extract_stats = extractor.extract_all(&mut articles).await; report.counts.extracted = (extract_stats.from_miniflux + extract_stats.from_readability) as i64; report.counts.excerpt_only = extract_stats.excerpt_only as i64; + // A handful of fetch failures is the normal state of the open web, so only a + // day well past the usual rate is worth degrading the run over. if extract_stats.fetch_failures > 0 { - report.warn(format!( - "{} articles fell back to a feed excerpt", - extract_stats.fetch_failures - )); + let share = extract_stats.fetch_failures as f64 / articles.len().max(1) as f64; + if share >= EXCERPT_FALLBACK_WARN_SHARE { + report.warn(format!( + "{} of {} articles ({:.0}%) fell back to a feed excerpt", + extract_stats.fetch_failures, + articles.len(), + share * 100.0 + )); + } else { + tracing::info!( + failures = extract_stats.fetch_failures, + articles = articles.len(), + "articles fell back to a feed excerpt" + ); + } } report.timings.record("extract", elapsed_ms(stage));