Files
the-daily-epub/src/curate/mod.rs
T
thalladaandClaude Fable 5.1 af2a3ceb49 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
2026-09-02 20:44:28 +00:00

309 lines
11 KiB
Rust

//! Personalized curation: signals → triage → admission → assessment → editor.
//!
//! ```text
//! ~400 eligible ─triage─▶ admission (120) ─assess/rank─▶ shortlist (60) ─editor─▶ editorial
//! ```
//!
//! [`Curator`] is the thin orchestration layer the `generate` pipeline calls; the
//! interesting logic lives in the stage modules. Every stage is safe to run with
//! no provider at all (`--skip-llm`): the cheap-signal blend stands in for selection
//! and feed excerpts stand in for summaries (notes §6). Scoring runs on the bulk
//! client; selection and editorial on the editor with per-call bulk fallback.
pub mod admit;
pub mod assess;
pub mod batch;
pub mod editor;
pub mod editorial;
pub mod embedding;
pub mod llm;
pub mod prefilter;
pub mod profile;
pub mod rank;
pub mod signals;
pub mod telemetry;
pub mod triage;
use jiff::{Timestamp, civil::Date};
use crate::config::Config;
use crate::db::Db;
use crate::types::{Candidate, Editorial, Lineup};
/// Runs the LLM curation stages against one day's articles (§12, §13, §14).
pub struct Curator {
pub config: Config,
pub db: Db,
pub llms: llm::Llms,
}
impl Curator {
/// An empty [`llm::Llms`] corresponds to `--skip-llm`: cheap-signal order is
/// used for selection and feed excerpts stand in for summaries (notes §6).
/// With only `bulk`, every editor call runs on the bulk provider (§4.2).
pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self {
Self { config, db, llms }
}
/// Deep assessment of the admitted set on the bulk client (§12.1), reusing
/// cached `article_assessments` rows within `assessment_reuse_days`.
///
/// A no-op under `--skip-llm`: like triage, nothing is read or written and
/// utility falls back to the present signals (§12.3, §17). When the bulk
/// provider is down or its budget trips, cached rows are still reused and
/// the failed batches simply stay unassessed. A batch the provider's
/// content filter rejects is bisected, and a rejected single article is
/// retried on the editor when that is another provider (`curate::batch`).
pub async fn assess(
&self,
candidates: &mut [Candidate],
rescore: bool,
profile_version: Option<i64>,
assessed_at: Timestamp,
) -> anyhow::Result<batch::StageSummary> {
let Some(bulk) = self.llms.bulk.as_ref() else {
tracing::info!("--skip-llm: deep assessment skipped");
return Ok(batch::StageSummary::default());
};
let admitted = candidates
.iter()
.filter(|candidate| candidate.stage == "admitted")
.count();
let span = tracing::info_span!("llm_assess", candidates = admitted);
let _guard = span.enter();
assess::run(
&self.db,
Some(bulk),
self.llms.editor.as_ref(),
&bulk.model,
candidates,
self.config.llm.deep_batch_size,
bulk.max_concurrent_requests,
self.config.curation.ranking.assessment_reuse_days,
rescore,
profile_version,
assessed_at,
self.config.llm.score_temperature,
&self.config.curation.sections,
)
.await
}
/// The editor: one call that assembles the issue from the shortlist (§13).
pub async fn select(&self, candidates: Vec<Candidate>, date: Date) -> anyhow::Result<Lineup> {
let sections = &self.config.curation.sections;
let soft_target = self.config.target_article_count;
let hard_max = self.config.curation.max_article_count;
let span = tracing::info_span!("llm_editor", candidates = candidates.len());
let _guard = span.enter();
match editor::select(
&self.llms,
candidates.clone(),
sections,
soft_target,
hard_max,
date,
)
.await
{
Ok(lineup) => Ok(lineup),
Err(error) => {
tracing::error!(%error, "editor and bulk fallback failed; selecting heuristically");
Ok(editor::select_without_llm(
candidates,
sections,
soft_target,
hard_max,
date,
))
}
}
}
/// Stage C: per-article summaries and the Brief (§14).
///
/// Never fails the run: a budget trip or an API error degrades to excerpts.
pub async fn editorial(&self, lineup: &Lineup) -> anyhow::Result<Editorial> {
Ok(self.editorial_timed(lineup).await?.0)
}
/// [`Self::editorial`] plus the `summaries` / `brief` stage timings (§15.4).
pub async fn editorial_timed(
&self,
lineup: &Lineup,
) -> anyhow::Result<(Editorial, editorial::EditorialTimings)> {
if self.llms.editor_or_bulk().is_none() {
tracing::info!("--skip-llm: using feed excerpts as summaries");
return Ok((
editorial::fallback_editorial(lineup),
editorial::EditorialTimings::default(),
));
}
let span = tracing::info_span!("llm_editorial", picks = lineup.picks.len());
let _guard = span.enter();
Ok(editorial::run_timed(
&self.llms,
lineup,
&self.config.editorial,
self.config.llm.editorial_temperature,
)
.await)
}
}
// ---------------------------------------------------------------------------
// Small text helpers shared by the prompt builders
// ---------------------------------------------------------------------------
/// Crude token estimate: DeepSeek averages ~4 characters per token for English
/// prose. Only used to size prompt budgets.
pub fn approx_tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
/// Strip markup and collapse whitespace, so article bodies can go into prompts
/// as plain text (cheaper and less confusing for the model than raw HTML).
///
/// Deliberately not [`crate::html::html_to_text`]: this one collapses runs of
/// whitespace and never builds a DOM, because it runs over every candidate
/// body on every run and only has to be good enough to size a prompt.
pub fn prompt_text(html: &str) -> String {
/// Does `tail` open the named element, i.e. `<name` or `</name`?
fn opens(tail: &str, name: &str) -> bool {
let bytes = tail.as_bytes();
bytes.len() > name.len() && bytes[1..=name.len()].eq_ignore_ascii_case(name.as_bytes())
}
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(ch) = rest.chars().next() {
if ch != '<' {
out.push(ch);
rest = &rest[ch.len_utf8()..];
continue;
}
// Drop <script>/<style> bodies wholesale rather than reading them aloud.
for (name, close) in [("script", "</script"), ("style", "</style")] {
if opens(rest, name) {
rest = match rest[1..].find(close) {
Some(idx) => &rest[1 + idx + close.len()..],
None => "",
};
break;
}
}
// A tag becomes a word boundary.
rest = match rest.find('>') {
Some(idx) => &rest[idx + 1..],
None => "",
};
out.push(' ');
}
let decoded = out
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&rsquo;", "'")
.replace("&mdash;", "—");
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// First `max_words` words of `text`, with an ellipsis when truncated.
pub fn truncate_words(text: &str, max_words: usize) -> String {
let mut words = text.split_whitespace();
let head: Vec<&str> = words.by_ref().take(max_words).collect();
let mut out = head.join(" ");
if words.next().is_some() {
out.push('…');
}
out
}
/// Truncate to roughly `max_tokens` tokens on a word boundary.
pub fn truncate_tokens(text: &str, max_tokens: usize) -> String {
let max_chars = max_tokens.saturating_mul(4);
if text.len() <= max_chars {
return text.to_string();
}
let mut cut = max_chars.min(text.len());
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let slice = &text[..cut];
let slice = slice
.rsplit_once(' ')
.map(|(head, _)| head)
.unwrap_or(slice);
format!("{slice}…")
}
/// Minimal XHTML escaping for text we drop into generated markup (§3.10).
pub fn escape_html(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}
/// Render plain text (possibly with blank-line paragraphs) as XHTML paragraphs.
pub fn text_to_paragraphs(text: &str) -> String {
text.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("<p>{}</p>", escape_html(&p.replace('\n', " "))))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_becomes_readable_text() {
let html = "<h1>Title</h1><p>First &amp; best.</p><script>alert('x')</script>\
<p>Second<br/>line</p><style>p{color:red}</style>";
assert_eq!(prompt_text(html), "Title First & best. Second line");
assert_eq!(prompt_text(""), "");
assert_eq!(prompt_text("no markup at all"), "no markup at all");
// Unicode survives byte-wise walking.
assert_eq!(prompt_text("<p>café — naïve</p>"), "café — naïve");
}
#[test]
fn word_and_token_truncation() {
assert_eq!(truncate_words("one two three", 5), "one two three");
assert_eq!(truncate_words("one two three", 2), "one two…");
let long = "word ".repeat(1000);
// 10 tokens ≈ 40 characters, cut back to a word boundary, plus the ellipsis.
let cut = truncate_tokens(&long, 10);
assert!(cut.len() <= 43, "{}", cut.len());
assert!(cut.split_whitespace().count() <= 10);
assert!(cut.ends_with('…'));
assert_eq!(truncate_tokens("short", 10), "short");
assert!(approx_tokens("abcd") <= 1);
}
#[test]
fn escaping_and_paragraphs() {
assert_eq!(escape_html("a<b>&'\""), "a&lt;b&gt;&amp;&#39;&quot;");
assert_eq!(
text_to_paragraphs("One\nline.\n\nTwo <b>."),
"<p>One line.</p>\n<p>Two &lt;b&gt;.</p>"
);
assert_eq!(text_to_paragraphs(" "), "");
}
}