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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164rQrMUZkf7uV1VGYuCFy4
This commit is contained in:
2026-09-05 04:08:06 +00:00
co-authored by Claude Opus 5
parent 17a88043ff
commit 6c2d74c734
2 changed files with 68 additions and 10 deletions
+46 -6
View File
@@ -432,6 +432,11 @@ pub async fn rebuild(
profile_path: &Path,
verdict_limit: usize,
) -> anyhow::Result<TasteProfile> {
// 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,
&current,
&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#"<outline text="Rust"/>"#).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);
}
}
+22 -4
View File
@@ -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));