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,
|
profile_path: &Path,
|
||||||
verdict_limit: usize,
|
verdict_limit: usize,
|
||||||
) -> anyhow::Result<TasteProfile> {
|
) -> 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 ratings = db.current_ratings(RATINGS_LOOKBACK_DAYS).await?;
|
||||||
let previous = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
|
let previous = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
|
||||||
let learned = if ratings.is_empty() {
|
let learned = if ratings.is_empty() {
|
||||||
@@ -464,21 +469,17 @@ pub async fn rebuild(
|
|||||||
let built_at = Timestamp::now();
|
let built_at = Timestamp::now();
|
||||||
store_version(db, next_version, built_at).await?;
|
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 {
|
let profile = TasteProfile {
|
||||||
text: build(
|
text: build(
|
||||||
&profile_file.body,
|
&profile_file.body,
|
||||||
&interests,
|
&interests,
|
||||||
&learned,
|
&learned,
|
||||||
¤t,
|
&ratings,
|
||||||
verdict_limit,
|
verdict_limit,
|
||||||
),
|
),
|
||||||
version: next_version,
|
version: next_version,
|
||||||
built_at,
|
built_at,
|
||||||
verdicts: current.len().min(verdict_limit),
|
verdicts: ratings.len().min(verdict_limit),
|
||||||
};
|
};
|
||||||
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -686,4 +687,43 @@ mod tests {
|
|||||||
assert_eq!(backend.calls(), 1);
|
assert_eq!(backend.calls(), 1);
|
||||||
assert!(backend.prompts()[0].user.contains("LOVED | A deep report"));
|
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,
|
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(
|
async fn run_stages(
|
||||||
ctx: &StageContext<'_>,
|
ctx: &StageContext<'_>,
|
||||||
window_start: Timestamp,
|
window_start: Timestamp,
|
||||||
@@ -412,11 +417,24 @@ async fn run_stages(
|
|||||||
let extract_stats = extractor.extract_all(&mut articles).await;
|
let extract_stats = extractor.extract_all(&mut articles).await;
|
||||||
report.counts.extracted = (extract_stats.from_miniflux + extract_stats.from_readability) as i64;
|
report.counts.extracted = (extract_stats.from_miniflux + extract_stats.from_readability) as i64;
|
||||||
report.counts.excerpt_only = extract_stats.excerpt_only 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 {
|
if extract_stats.fetch_failures > 0 {
|
||||||
report.warn(format!(
|
let share = extract_stats.fetch_failures as f64 / articles.len().max(1) as f64;
|
||||||
"{} articles fell back to a feed excerpt",
|
if share >= EXCERPT_FALLBACK_WARN_SHARE {
|
||||||
extract_stats.fetch_failures
|
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));
|
report.timings.record("extract", elapsed_ms(stage));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user