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:
@@ -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,
|
||||
¤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#"<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
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user