Provider-agnostic LLM registry, config check, migration runbook
[llm] assigns the bulk and editor roles by name over a [providers.*] registry (kind = openai | anthropic, per-provider model, effort, daily ceiling and price table); DeepseekBackend becomes OpenAiCompatibleBackend (reasoning_effort passthrough), AnthropicBackend builds from the same ProviderConfig, meters and provider_costs are keyed by provider name. Gemini 3.8 Flash is declared via Google's OpenAI-compatible endpoint so switching the editor is one line (or DAILY_EPUB_LLM__EDITOR=gemini for an A/B dry run). Stale [deepseek]/[anthropic] tables, the top-level max_daily_usd and the old key env vars fail loudly. daily-epub config check validates and prints the resolved roles, models, key presence and paths without opening the database. docs/runbooks/curation-v2-migration.md walks the server upgrade from v1. Registry implemented by a Claude agent from an orchestrator brief; verified fmt/clippy(-W dead_code)/test green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
@@ -481,8 +481,8 @@ pub async fn run(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{CurationConfig, DeepseekConfig};
|
||||
use crate::curate::llm::{MockBackend, UsageMeter};
|
||||
use crate::config::{CurationConfig, ProviderConfig};
|
||||
use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::{article, with_social};
|
||||
use crate::curate::signals::{Neighbour, TopInterest};
|
||||
use crate::types::{TokenUsage, Triage};
|
||||
@@ -520,7 +520,7 @@ mod tests {
|
||||
LlmClient::with_backend(
|
||||
"deepseek-v4-flash",
|
||||
"SYSTEM".into(),
|
||||
UsageMeter::new(&DeepseekConfig::default(), limit_usd),
|
||||
UsageMeter::with_prices(PriceTable::from(&ProviderConfig::deepseek()), limit_usd),
|
||||
backend,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -637,7 +637,7 @@ pub fn select_without_llm(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AnthropicConfig, CurationConfig, DeepseekConfig};
|
||||
use crate::config::{CurationConfig, ProviderConfig};
|
||||
use crate::curate::llm::{ChatBackend, LlmClient, MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::article;
|
||||
use crate::curate::signals::{Neighbour, TopInterest};
|
||||
@@ -696,9 +696,9 @@ mod tests {
|
||||
|
||||
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
|
||||
let prices = if provider == "anthropic" {
|
||||
PriceTable::anthropic(&AnthropicConfig::default())
|
||||
PriceTable::from(&ProviderConfig::anthropic())
|
||||
} else {
|
||||
PriceTable::deepseek(&DeepseekConfig::default())
|
||||
PriceTable::from(&ProviderConfig::deepseek())
|
||||
};
|
||||
LlmClient::with_backend_options(
|
||||
provider,
|
||||
@@ -1079,9 +1079,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() {
|
||||
let editor = Arc::new(MockBackend::new());
|
||||
editor.push_llm_error(LlmError::Refusal {
|
||||
provider: "anthropic",
|
||||
});
|
||||
editor.push_llm_error(LlmError::refusal("anthropic"));
|
||||
let bulk = Arc::new(MockBackend::new());
|
||||
bulk.push(picks_json(5), TokenUsage::default());
|
||||
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
|
||||
|
||||
+14
-19
@@ -1,4 +1,4 @@
|
||||
//! Claude-first summaries and The Brief, with per-call DeepSeek fallback (§14).
|
||||
//! Editor-first summaries and The Brief, with per-call bulk fallback (§14).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
@@ -12,7 +12,6 @@ use crate::config::{EditorialConfig, SummaryModel};
|
||||
use crate::types::{ArticleId, Editorial, Lineup, Pick};
|
||||
|
||||
pub const FALLBACK_SUMMARY_WORDS: usize = 45;
|
||||
pub const SUMMARY_CONCURRENCY: usize = 4;
|
||||
|
||||
pub const SUMMARY_INSTRUCTIONS: &str = "\
|
||||
TASK: write the newspaper abstract for one article in today's issue.
|
||||
@@ -102,9 +101,7 @@ pub async fn summarize_article(
|
||||
let response: SummaryResponse = llm.complete_json(&prompt, temperature).await?;
|
||||
let summary = response.summary.trim().to_string();
|
||||
if summary.is_empty() {
|
||||
return Err(LlmError::EmptyResponse {
|
||||
provider: llm.provider,
|
||||
});
|
||||
return Err(LlmError::empty_response(llm.provider()));
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -172,12 +169,17 @@ pub async fn summarize_all(
|
||||
temperature: f32,
|
||||
) -> BTreeMap<ArticleId, String> {
|
||||
let (primary, fallback) = summary_clients(llms, config.summary_model);
|
||||
// The summary provider's own `max_concurrent_requests` bounds the fan-out.
|
||||
let concurrency = primary
|
||||
.map(|client| client.max_concurrent_requests)
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
stream::iter(lineup.picks.iter())
|
||||
.map(|pick| async move {
|
||||
let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
|
||||
(pick.article.id, summary)
|
||||
})
|
||||
.buffer_unordered(SUMMARY_CONCURRENCY)
|
||||
.buffer_unordered(concurrency)
|
||||
.filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
|
||||
.collect()
|
||||
.await
|
||||
@@ -232,10 +234,7 @@ pub async fn brief(
|
||||
) -> Result<String, LlmError> {
|
||||
let prompt = build_brief_prompt(lineup, summaries);
|
||||
let Some(primary) = llms.editor_or_bulk() else {
|
||||
return Err(LlmError::Api {
|
||||
provider: "editorial",
|
||||
message: "no provider configured".into(),
|
||||
});
|
||||
return Err(LlmError::api("editorial", "no provider configured"));
|
||||
};
|
||||
let response = match primary
|
||||
.complete_json::<BriefResponse>(&prompt, temperature)
|
||||
@@ -258,9 +257,7 @@ pub async fn brief(
|
||||
};
|
||||
let brief = response.brief.trim().to_string();
|
||||
if brief.is_empty() {
|
||||
return Err(LlmError::EmptyResponse {
|
||||
provider: primary.provider,
|
||||
});
|
||||
return Err(LlmError::empty_response(primary.provider()));
|
||||
}
|
||||
Ok(brief)
|
||||
}
|
||||
@@ -381,7 +378,7 @@ pub fn summary_to_html(summary: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AnthropicConfig, DeepseekConfig};
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::article;
|
||||
use crate::types::TokenUsage;
|
||||
@@ -420,9 +417,9 @@ mod tests {
|
||||
|
||||
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
|
||||
let prices = if provider == "anthropic" {
|
||||
PriceTable::anthropic(&AnthropicConfig::default())
|
||||
PriceTable::from(&ProviderConfig::anthropic())
|
||||
} else {
|
||||
PriceTable::deepseek(&DeepseekConfig::default())
|
||||
PriceTable::from(&ProviderConfig::deepseek())
|
||||
};
|
||||
LlmClient::with_backend_options(
|
||||
provider,
|
||||
@@ -534,9 +531,7 @@ mod tests {
|
||||
r#"{"summary": "Opus wrote this one."}"#,
|
||||
TokenUsage::default(),
|
||||
);
|
||||
editor.push_llm_error(LlmError::Refusal {
|
||||
provider: "anthropic",
|
||||
});
|
||||
editor.push_llm_error(LlmError::refusal("anthropic"));
|
||||
editor.push(BRIEF_FIXTURE, TokenUsage::default());
|
||||
let bulk = Arc::new(MockBackend::new());
|
||||
bulk.push(
|
||||
|
||||
+538
-258
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -39,7 +39,7 @@ pub struct Curator {
|
||||
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 DeepSeek (§4.2).
|
||||
/// 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 }
|
||||
}
|
||||
@@ -67,15 +67,15 @@ impl Curator {
|
||||
assess::run(
|
||||
&self.db,
|
||||
Some(bulk),
|
||||
&self.config.deepseek.model,
|
||||
&bulk.model,
|
||||
candidates,
|
||||
self.config.deepseek.deep_batch_size,
|
||||
self.config.deepseek.max_concurrent_requests,
|
||||
self.config.llm.deep_batch_size,
|
||||
bulk.max_concurrent_requests,
|
||||
self.config.curation.ranking.assessment_reuse_days,
|
||||
rescore,
|
||||
profile_version,
|
||||
assessed_at,
|
||||
self.config.deepseek.score_temperature,
|
||||
self.config.llm.score_temperature,
|
||||
&self.config.curation.sections,
|
||||
)
|
||||
.await
|
||||
@@ -137,7 +137,7 @@ impl Curator {
|
||||
&self.llms,
|
||||
lineup,
|
||||
&self.config.editorial,
|
||||
self.config.deepseek.editorial_temperature,
|
||||
self.config.llm.editorial_temperature,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::llm::{MockBackend, UsageMeter};
|
||||
use crate::config::DeepseekConfig;
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::types::{RatingEvent, TokenUsage};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -667,7 +667,7 @@ mod tests {
|
||||
let llm = LlmClient::with_backend(
|
||||
"deepseek-v4-flash",
|
||||
initial.text,
|
||||
UsageMeter::new(&DeepseekConfig::default(), 2.0),
|
||||
UsageMeter::for_provider(&ProviderConfig::deepseek()),
|
||||
backend.clone(),
|
||||
);
|
||||
let rebuilt = rebuild(&db, &llm, &opml, &profile_path, 60).await.unwrap();
|
||||
|
||||
@@ -1370,7 +1370,7 @@ mod tests {
|
||||
"2026-09-01",
|
||||
"2026-09-01T09:30:00Z",
|
||||
"2026-09-01T09:45:00Z",
|
||||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14}}"#,
|
||||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14},"gemini":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.07}}"#,
|
||||
),
|
||||
] {
|
||||
let run_id = db
|
||||
@@ -1452,8 +1452,9 @@ mod tests {
|
||||
"exploration rated positively: 1",
|
||||
"cost per day (anthropic): $0.043",
|
||||
"cost per day (deepseek): $0.020",
|
||||
"cost per day (gemini): $0.005",
|
||||
"cost per day (voyage): $0.001",
|
||||
"cost per day (total): $0.064",
|
||||
"cost per day (total): $0.069",
|
||||
"mean generation time: 15m00s (3 runs)",
|
||||
] {
|
||||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||||
|
||||
@@ -500,8 +500,8 @@ pub async fn run(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DeepseekConfig;
|
||||
use crate::curate::llm::{MockBackend, UsageMeter};
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
|
||||
use crate::curate::prefilter::tests::article;
|
||||
use crate::curate::signals::{Neighbour, TopInterest};
|
||||
use crate::types::TokenUsage;
|
||||
@@ -584,11 +584,11 @@ mod tests {
|
||||
r#"{"articles":[{"id":42,"interest":8,"kind":"essay","why":"first answer"}]}"#,
|
||||
TokenUsage::default(),
|
||||
);
|
||||
let config = DeepseekConfig::default();
|
||||
let config = ProviderConfig::deepseek();
|
||||
let llm = LlmClient::with_backend(
|
||||
&config.model,
|
||||
"profile".into(),
|
||||
UsageMeter::new(&config, 10.0),
|
||||
UsageMeter::with_prices(PriceTable::from(&config), 10.0),
|
||||
backend.clone(),
|
||||
);
|
||||
let pool = HashSet::from([42]);
|
||||
|
||||
Reference in New Issue
Block a user