Bisect provider-rejected batches in triage and deep assessment

DeepSeek's content filter rejects a whole request (400 "Content Exists
Risk") when one article trips it, which cost the other articles in the
batch their assessment and retried them every run. New curate/batch.rs
runs both stages through a bisecting runner: a rejected batch is split
until the offending article is isolated, that article is retried once on
the editor provider when it is a different one, and a still-rejected
article is recorded as a provider_rejected assessment row so it is not
retried for assessment_reuse_days. Cache reuse accepts rows from either
configured model. Each stage logs reused/requested/rejected counts, the
curation: line shows rejections when non-zero, explain prints them, and
the llm_assess span reports the deep-set size.

Implemented by a Claude agent from an orchestrator brief; verified
fmt/clippy(-W dead_code)/test green (354 lib tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 20:44:28 +00:00
co-authored by Claude Fable 5.1
parent 7a842b61b6
commit af2a3ceb49
14 changed files with 1711 additions and 127 deletions
+56
View File
@@ -429,6 +429,12 @@ pub async fn render_explain(db: &Db, row: &ExplainRow) -> Result<String, sqlx::E
let rationale = assessment
.get::<Option<String>, _>("rationale")
.unwrap_or_default();
if assessment.get::<Option<String>, _>("kind").as_deref()
== Some(super::triage::PROVIDER_REJECTED)
{
let _ = writeln!(out, " {stage}: rejected by provider — {rationale}");
continue;
}
if stage == "deep" {
let _ = writeln!(
out,
@@ -1007,6 +1013,56 @@ mod tests {
assert_eq!(parsed["exploration"], true);
}
#[tokio::test]
async fn explain_names_provider_rejections_instead_of_scores() {
let (_dir, db) = db_with_articles(&[1]).await;
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
write(
&db,
&CandidateRun {
run_id,
article_id: 1,
stage: "admitted",
excluded_reason: None,
admitted_by: Some("[\"interest\"]"),
signals_json: "{}",
utility: None,
rank_utility: None,
cluster_id: None,
cluster_rank: None,
editor_why: None,
},
)
.await
.unwrap();
sqlx::query(
"INSERT INTO article_assessments
(article_id, stage, model, prompt_version, score, fit, kind, rationale, assessed_at)
VALUES (1, 'triage', 'deepseek-v4-flash', 1, NULL, NULL, 'provider_rejected',
'deepseek: 400 Bad Request: Content Exists Risk', '2026-09-02T04:00:00Z'),
(1, 'deep', 'deepseek-v4-flash', 1, NULL, NULL, 'provider_rejected',
'deepseek: returned a refusal', '2026-09-02T04:00:00Z')",
)
.execute(db.pool())
.await
.unwrap();
let text = explain(&db, date(), None, &ExplainTarget::Article(1))
.await
.unwrap();
assert!(
text.contains(
" triage: rejected by provider — deepseek: 400 Bad Request: Content Exists Risk\n"
),
"{text}"
);
assert!(
text.contains(" deep: rejected by provider — deepseek: returned a refusal\n"),
"{text}"
);
assert!(!text.contains("interest —"), "{text}");
assert!(!text.contains("quality —"), "{text}");
}
#[tokio::test]
async fn rows_are_upserted_with_every_column_replaced() {
let (_dir, db) = db_with_articles(&[1]).await;