Merge curation-v2-step3 into curation-v2 (steps 2 and 3 united)
Union merge of the Claude editor (step 2) and the embeddings/signals/telemetry work (step 3): both config trees, per-provider LLM budgets plus the Voyage meter, one startup provider log, runs.config_json with ranking and voyage settings, and selected picks' why lines landing in candidate_runs.editor_why. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
+696
-10
@@ -23,7 +23,7 @@
|
||||
//! issue itself are upserted, `issue_articles` is replaced wholesale, and the
|
||||
//! published filenames are derived from the date.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -32,14 +32,15 @@ use jiff::{Timestamp, Zoned};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::curate::llm::{Llms, PriceTable, UsageMeter};
|
||||
use crate::curate::{Curator, editorial, profile};
|
||||
use crate::curate::{Curator, editorial, embedding, prefilter, profile, signals, telemetry};
|
||||
use crate::db::Db;
|
||||
use crate::extract::Extractor;
|
||||
use crate::miniflux::MinifluxClient;
|
||||
use crate::publish::Published;
|
||||
use crate::report::{ProviderUsage, RunReport, RunStatus};
|
||||
use crate::types::{
|
||||
Article, Artifact, Colophon, Edition, Issue, IssueMeta, Lineup, Models, reading_minutes,
|
||||
Article, ArticleId, Artifact, Colophon, Edition, Issue, IssueMeta, Lineup, Models,
|
||||
reading_minutes,
|
||||
};
|
||||
use crate::{comments, dedupe, epub, http, miniflux, publish, social, world};
|
||||
|
||||
@@ -56,6 +57,8 @@ pub struct GenerateOptions {
|
||||
pub max_articles: Option<usize>,
|
||||
/// `--skip-llm`: no DeepSeek call at all.
|
||||
pub skip_llm: bool,
|
||||
/// `--skip-embeddings`: read the cache but make zero Voyage calls.
|
||||
pub skip_embeddings: bool,
|
||||
}
|
||||
|
||||
/// What one run produced, for the caller to print (§3.13).
|
||||
@@ -196,10 +199,12 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
|
||||
soft_target,
|
||||
hard_max,
|
||||
skip_llm = opts.skip_llm,
|
||||
skip_embeddings = opts.skip_embeddings,
|
||||
voyage_enabled = config.voyage.enabled,
|
||||
out = %out_dir.display(),
|
||||
"starting run"
|
||||
);
|
||||
log_resolved_providers(config, opts.skip_llm);
|
||||
log_resolved_providers(config, opts.skip_llm, opts.skip_embeddings);
|
||||
|
||||
let run_id = db.start_run(date, started_at).await?;
|
||||
let mut report = RunReport::new(date, started_at);
|
||||
@@ -213,6 +218,7 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
|
||||
let ctx = StageContext {
|
||||
config,
|
||||
db,
|
||||
run_id,
|
||||
date,
|
||||
soft_target,
|
||||
hard_max,
|
||||
@@ -220,6 +226,7 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
|
||||
out_dir,
|
||||
dry_run: opts.dry_run,
|
||||
skip_llm: opts.skip_llm,
|
||||
skip_embeddings: opts.skip_embeddings,
|
||||
};
|
||||
let stages = match run_stages(&ctx, window_start, window_end, &mut report).await {
|
||||
Ok(stages) => {
|
||||
@@ -275,6 +282,7 @@ struct StageOutput {
|
||||
struct StageContext<'a> {
|
||||
config: &'a Config,
|
||||
db: &'a Db,
|
||||
run_id: i64,
|
||||
date: Date,
|
||||
soft_target: usize,
|
||||
hard_max: usize,
|
||||
@@ -282,6 +290,7 @@ struct StageContext<'a> {
|
||||
out_dir: PathBuf,
|
||||
dry_run: bool,
|
||||
skip_llm: bool,
|
||||
skip_embeddings: bool,
|
||||
}
|
||||
|
||||
async fn run_stages(
|
||||
@@ -369,7 +378,11 @@ async fn run_stages(
|
||||
report.counts.social_hits = enricher.enrich_all(&mut articles).await as i64;
|
||||
report.timings.record("social", elapsed_ms(stage));
|
||||
|
||||
// --- Stage 6: heuristic pre-filter (§3.5) ---
|
||||
// --- Stage 6: hygiene, embeddings, and cheap signals (§8.1, §9) ---
|
||||
let embeddings = build_embedding_service(ctx, report);
|
||||
let feature_signals = prepare_features(ctx, &articles, &embeddings, report).await;
|
||||
|
||||
// --- Stage 6b: the old heuristic pre-filter still gates in this step (§21) ---
|
||||
let stage = Timestamp::now();
|
||||
let bulk_meter =
|
||||
UsageMeter::with_prices(PriceTable::deepseek(&config.deepseek), config.max_daily_usd);
|
||||
@@ -399,6 +412,28 @@ async fn run_stages(
|
||||
.await
|
||||
.context("running the heuristic pre-filter")?;
|
||||
report.counts.candidates = candidates.len() as i64;
|
||||
let admitted = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.article.id)
|
||||
.collect::<Vec<_>>();
|
||||
let admitted_set = admitted.iter().copied().collect::<HashSet<_>>();
|
||||
let not_admitted = feature_signals
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|id| !admitted_set.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
record_stage(
|
||||
ctx,
|
||||
&feature_signals,
|
||||
¬_admitted,
|
||||
"eligible",
|
||||
Some("not_admitted"),
|
||||
)
|
||||
.await
|
||||
.context("recording prefilter telemetry")?;
|
||||
record_stage(ctx, &feature_signals, &admitted, "admitted", None)
|
||||
.await
|
||||
.context("recording prefilter telemetry")?;
|
||||
report.timings.record("prefilter", elapsed_ms(stage));
|
||||
|
||||
// --- Stage 7: LLM scoring, then selection (§3.6 A + B) ---
|
||||
@@ -410,12 +445,54 @@ async fn run_stages(
|
||||
}
|
||||
report.counts.llm_scored = candidates.iter().filter(|c| c.llm.is_some()).count() as i64;
|
||||
report.counts.llm_unscored = report.counts.candidates - report.counts.llm_scored;
|
||||
let assessed = candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.llm.is_some())
|
||||
.map(|candidate| candidate.article.id)
|
||||
.collect::<Vec<_>>();
|
||||
record_stage(ctx, &feature_signals, &assessed, "assessed", None)
|
||||
.await
|
||||
.context("recording assessment telemetry")?;
|
||||
// Every prefilter survivor goes to the old selector, scored or not.
|
||||
record_stage(ctx, &feature_signals, &admitted, "shortlisted", None)
|
||||
.await
|
||||
.context("recording shortlist telemetry")?;
|
||||
|
||||
let mut lineup = curator
|
||||
.select(candidates, date)
|
||||
.await
|
||||
.context("selecting the lineup")?;
|
||||
report.counts.selected = lineup.picks.len() as i64;
|
||||
let selected = lineup
|
||||
.picks
|
||||
.iter()
|
||||
.map(|pick| pick.article.id)
|
||||
.collect::<Vec<_>>();
|
||||
let selected_set = selected.iter().copied().collect::<HashSet<_>>();
|
||||
let not_selected = admitted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !selected_set.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
// The editor's one-line `why` (§13) lands in `candidate_runs.editor_why` so
|
||||
// `explain` can quote it; heuristic picks leave it NULL.
|
||||
let selected_with_why = lineup
|
||||
.picks
|
||||
.iter()
|
||||
.map(|pick| (pick.article.id, pick.why.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
record_stage_with_why(ctx, &feature_signals, &selected_with_why, "selected", None)
|
||||
.await
|
||||
.context("recording selection telemetry")?;
|
||||
record_stage(
|
||||
ctx,
|
||||
&feature_signals,
|
||||
¬_selected,
|
||||
"shortlisted",
|
||||
Some("not_selected"),
|
||||
)
|
||||
.await
|
||||
.context("recording selection telemetry")?;
|
||||
if lineup.picks.is_empty() {
|
||||
report.warn("the lineup is empty — check the lookback window and pre-filter");
|
||||
}
|
||||
@@ -577,6 +654,253 @@ async fn run_stages(
|
||||
})
|
||||
}
|
||||
|
||||
/// The cheap signals and hygiene outcome for one eligible article (§9).
|
||||
#[derive(Debug, Clone)]
|
||||
struct FeatureSignals {
|
||||
signals: signals::Signals,
|
||||
auto_include: bool,
|
||||
}
|
||||
|
||||
/// The embedding cache with a Voyage client behind it, or cache-only under
|
||||
/// `--skip-embeddings`, `voyage.enabled = false` or a missing key (§16, §17).
|
||||
fn build_embedding_service(
|
||||
ctx: &StageContext<'_>,
|
||||
report: &mut RunReport,
|
||||
) -> embedding::EmbeddingService {
|
||||
let (db, voyage) = (ctx.db.clone(), ctx.config.voyage.clone());
|
||||
if ctx.skip_embeddings {
|
||||
tracing::info!("--skip-embeddings: using cached vectors only, no Voyage calls");
|
||||
return embedding::EmbeddingService::cached_only(db, voyage);
|
||||
}
|
||||
if !voyage.enabled {
|
||||
tracing::info!("voyage disabled: using cached embeddings only");
|
||||
return embedding::EmbeddingService::cached_only(db, voyage);
|
||||
}
|
||||
match embedding::EmbeddingService::real(db.clone(), voyage.clone()) {
|
||||
Ok(service) => service,
|
||||
Err(embedding::EmbeddingError::MissingApiKey) => {
|
||||
tracing::warn!(
|
||||
"voyage enabled but {} is unset; using cached embeddings only",
|
||||
embedding::VOYAGE_API_KEY_ENV
|
||||
);
|
||||
embedding::EmbeddingService::cached_only(db, voyage)
|
||||
}
|
||||
Err(error) => {
|
||||
report.warn(format!(
|
||||
"Voyage unavailable; using cached embeddings only: {error}"
|
||||
));
|
||||
embedding::EmbeddingService::cached_only(db, voyage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hygiene, embeddings and cheap signals for every article (§8.1, §9).
|
||||
///
|
||||
/// Hygiene-excluded articles get thin `candidate_runs` rows; every other
|
||||
/// article gets an `eligible` row with its `signals_json`. Nothing here can
|
||||
/// fail the run: embeddings and the learned signals degrade to absent (§17).
|
||||
async fn prepare_features(
|
||||
ctx: &StageContext<'_>,
|
||||
articles: &[Article],
|
||||
service: &embedding::EmbeddingService,
|
||||
report: &mut RunReport,
|
||||
) -> HashMap<ArticleId, FeatureSignals> {
|
||||
let (config, db) = (ctx.config, ctx.db);
|
||||
let hygiene = match prefilter::PrefilterContext::load(db, ctx.date).await {
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
report.warn(format!(
|
||||
"could not load hygiene history; signals skipped: {error}"
|
||||
));
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let published = hygiene
|
||||
.already_published
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<HashSet<_>>();
|
||||
let rejected = hygiene
|
||||
.recently_rejected
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<HashSet<_>>();
|
||||
let mut eligible = Vec::new();
|
||||
for article in articles {
|
||||
let auto_include = prefilter::is_auto_include(article, &config.curation);
|
||||
let reason = if published.contains(&article.id) {
|
||||
Some("published_before")
|
||||
} else if !auto_include && prefilter::is_blocked(article, &config.curation) {
|
||||
Some("blocked")
|
||||
} else if !auto_include && rejected.contains(&article.id) {
|
||||
Some("recently_rejected")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match reason {
|
||||
Some(reason) => {
|
||||
if let Err(error) =
|
||||
telemetry::thin_excluded(db, ctx.run_id, article.id, reason).await
|
||||
{
|
||||
report.warn(format!(
|
||||
"could not record excluded candidate {}: {error}",
|
||||
article.id
|
||||
));
|
||||
}
|
||||
}
|
||||
None => eligible.push(article.clone()),
|
||||
}
|
||||
}
|
||||
report.counts.eligible = eligible.len() as i64;
|
||||
|
||||
// --- embed (§7.1, §7.2) ---
|
||||
let stage = Timestamp::now();
|
||||
let article_embeddings = match service.articles(&eligible).await {
|
||||
Ok(embeddings) => embeddings,
|
||||
Err(error) => {
|
||||
report.warn(format!("article embedding stage degraded: {error}"));
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
report.counts.embedded = article_embeddings.len() as i64;
|
||||
let interests =
|
||||
match profile::load_standing_interests(&config.interests_opml, &config.profile_path) {
|
||||
Ok(interests) => interests,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not load standing interests for embeddings");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let interest_embeddings = match service.interests(&interests).await {
|
||||
Ok(embeddings) => embeddings,
|
||||
Err(error) => {
|
||||
report.warn(format!("interest embedding stage degraded: {error}"));
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
if let Some(meter) = service.meter() {
|
||||
report.voyage_tokens = meter.total_tokens();
|
||||
report.voyage_cost_usd = meter.cost_usd();
|
||||
}
|
||||
tracing::info!(
|
||||
eligible = eligible.len(),
|
||||
embedded = article_embeddings.len(),
|
||||
interests = interest_embeddings.len(),
|
||||
voyage_tokens = report.voyage_tokens,
|
||||
"embeddings ready"
|
||||
);
|
||||
report.timings.record("embed", elapsed_ms(stage));
|
||||
|
||||
// --- signals (§9, §12.2, §12.4) ---
|
||||
let stage = Timestamp::now();
|
||||
let ranking = &config.curation.ranking;
|
||||
let (mut computed, preference) = match signals::compute_all(
|
||||
db,
|
||||
&eligible,
|
||||
&article_embeddings,
|
||||
&interest_embeddings,
|
||||
&config.voyage,
|
||||
ranking,
|
||||
Timestamp::now(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
report.warn(format!("signal computation degraded: {error:#}"));
|
||||
let state = signals::PreferenceState::default();
|
||||
(
|
||||
signals::compute(
|
||||
&eligible,
|
||||
&article_embeddings,
|
||||
&interest_embeddings,
|
||||
&state,
|
||||
ranking,
|
||||
),
|
||||
state.summary(),
|
||||
)
|
||||
}
|
||||
};
|
||||
report.counts.rated_with_embeddings = preference.rated_with_embeddings as i64;
|
||||
let mut output = HashMap::new();
|
||||
for article in &eligible {
|
||||
let auto_include = prefilter::is_auto_include(article, &config.curation);
|
||||
let signals = computed
|
||||
.remove(&article.id)
|
||||
.unwrap_or_else(|| signals::Signals::baseline(article));
|
||||
output.insert(
|
||||
article.id,
|
||||
FeatureSignals {
|
||||
signals,
|
||||
auto_include,
|
||||
},
|
||||
);
|
||||
}
|
||||
let eligible_ids = eligible
|
||||
.iter()
|
||||
.map(|article| article.id)
|
||||
.collect::<Vec<_>>();
|
||||
if let Err(error) = record_stage(ctx, &output, &eligible_ids, "eligible", None).await {
|
||||
report.warn(format!("could not record eligible candidates: {error}"));
|
||||
}
|
||||
report.timings.record("signals", elapsed_ms(stage));
|
||||
output
|
||||
}
|
||||
|
||||
/// Upsert the `candidate_runs` row of every listed article at a new stage
|
||||
/// (§7.4). Articles without signals (hygiene-excluded) are left alone.
|
||||
async fn record_stage(
|
||||
ctx: &StageContext<'_>,
|
||||
features: &HashMap<ArticleId, FeatureSignals>,
|
||||
ids: &[ArticleId],
|
||||
stage: &str,
|
||||
excluded_reason: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let rows = ids.iter().map(|id| (*id, None)).collect::<Vec<_>>();
|
||||
record_stage_with_why(ctx, features, &rows, stage, excluded_reason).await
|
||||
}
|
||||
|
||||
/// [`record_stage`] with the editor's `why` per article (§13, §7.4).
|
||||
async fn record_stage_with_why(
|
||||
ctx: &StageContext<'_>,
|
||||
features: &HashMap<ArticleId, FeatureSignals>,
|
||||
rows: &[(ArticleId, Option<&str>)],
|
||||
stage: &str,
|
||||
excluded_reason: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let admitted = matches!(stage, "admitted" | "assessed" | "shortlisted" | "selected");
|
||||
for (id, editor_why) in rows {
|
||||
let Some(feature) = features.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let json = telemetry::serialize_signals(&feature.signals, feature.auto_include);
|
||||
let admitted_by = admitted.then_some(if feature.auto_include {
|
||||
"[\"auto\"]"
|
||||
} else {
|
||||
"[\"prefilter\"]"
|
||||
});
|
||||
telemetry::write(
|
||||
ctx.db,
|
||||
&telemetry::CandidateRun {
|
||||
run_id: ctx.run_id,
|
||||
article_id: *id,
|
||||
stage,
|
||||
excluded_reason,
|
||||
admitted_by,
|
||||
signals_json: &json,
|
||||
utility: None,
|
||||
rank_utility: None,
|
||||
cluster_id: None,
|
||||
cluster_rank: None,
|
||||
editor_why: *editor_why,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("recording candidate {id} at stage {stage}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert/refresh the `articles` rows and stamp the returned ids back on (§3.13).
|
||||
async fn persist_articles(db: &Db, articles: &mut [Article]) -> Result<()> {
|
||||
for article in articles.iter_mut() {
|
||||
@@ -701,9 +1025,10 @@ pub fn issue_size_bounds(config: &Config, max_articles: Option<usize>) -> (usize
|
||||
}
|
||||
|
||||
/// Startup line naming the resolved models and whether each provider is on
|
||||
/// (§19): the root config ignores unknown sections, so an `[anthropics]` typo
|
||||
/// would otherwise be silent. Keys are never logged, only their presence.
|
||||
fn log_resolved_providers(config: &Config, skip_llm: bool) {
|
||||
/// (§19): the root config ignores unknown sections, so an `[anthropics]` or
|
||||
/// `[voyages]` typo would otherwise be silent. Keys are never logged, only
|
||||
/// their presence.
|
||||
fn log_resolved_providers(config: &Config, skip_llm: bool, skip_embeddings: bool) {
|
||||
let has_key = |key: Option<&str>| key.is_some_and(|k| !k.trim().is_empty());
|
||||
tracing::info!(
|
||||
bulk_model = %config.deepseek.model,
|
||||
@@ -716,6 +1041,12 @@ fn log_resolved_providers(config: &Config, skip_llm: bool) {
|
||||
editor_effort = %config.anthropic.effort,
|
||||
editor_max_daily_usd = config.anthropic.max_daily_usd,
|
||||
summary_model = ?config.editorial.summary_model,
|
||||
embedding_model = %config.voyage.model,
|
||||
embedding_enabled = !skip_embeddings
|
||||
&& config.voyage.enabled
|
||||
&& has_key(config.voyage.api_key.as_deref()),
|
||||
embedding_dimension = config.voyage.output_dimension,
|
||||
embedding_max_daily_usd = config.voyage.max_daily_usd,
|
||||
"resolved providers"
|
||||
);
|
||||
}
|
||||
@@ -730,20 +1061,25 @@ const PROMPT_VERSIONS: &[(&str, u32)] = &[
|
||||
("profile", 2),
|
||||
];
|
||||
|
||||
/// The resolved `[curation]`, `[editorial]`, model names and prompt versions
|
||||
/// written to `runs.config_json` (§7.6, §19). Never includes keys.
|
||||
/// The resolved `[curation]` (ranking included), `[editorial]`, `[voyage]`,
|
||||
/// model names and prompt versions written to `runs.config_json` (§7.6, §19).
|
||||
/// Never includes keys.
|
||||
fn resolved_run_config(config: &Config, soft_target: usize, hard_max: usize) -> serde_json::Value {
|
||||
let mut curation = config.curation.clone();
|
||||
curation.max_article_count = hard_max;
|
||||
let mut voyage = config.voyage.clone();
|
||||
voyage.api_key = None;
|
||||
serde_json::json!({
|
||||
"target_article_count": soft_target,
|
||||
"prefilter_keep": config.prefilter_keep,
|
||||
"curation": curation,
|
||||
"editorial": config.editorial,
|
||||
"voyage": voyage,
|
||||
"models": {
|
||||
"bulk": config.deepseek.model,
|
||||
"editor": if config.anthropic.enabled { config.anthropic.model.as_str() } else { "disabled" },
|
||||
"editor_effort": config.anthropic.effort,
|
||||
"embedding": if config.voyage.enabled { config.voyage.model.as_str() } else { "disabled" },
|
||||
},
|
||||
"prompt_versions": PROMPT_VERSIONS
|
||||
.iter()
|
||||
@@ -815,9 +1151,19 @@ mod tests {
|
||||
let mut config = Config::default();
|
||||
config.anthropic.api_key = Some("sk-secret".into());
|
||||
config.deepseek.api_key = Some("ds-secret".into());
|
||||
config.voyage.api_key = Some("pa-secret".into());
|
||||
let value = resolved_run_config(&config, 6, 6);
|
||||
assert_eq!(value["target_article_count"], 6);
|
||||
assert_eq!(value["curation"]["max_article_count"], 6);
|
||||
assert_eq!(value["curation"]["ranking"]["deep_keep"], 120);
|
||||
assert_eq!(
|
||||
value["curation"]["ranking"]["weights"]["utility"]["quality"],
|
||||
0.40
|
||||
);
|
||||
assert_eq!(value["voyage"]["model"], "voyage-4-lite");
|
||||
assert_eq!(value["voyage"]["output_dimension"], 512);
|
||||
assert!(value["voyage"]["api_key"].is_null());
|
||||
assert_eq!(value["models"]["embedding"], "voyage-4-lite");
|
||||
assert_eq!(value["editorial"]["summary_model"], "editor");
|
||||
assert_eq!(value["editorial"]["summary_input_tokens"], 3000);
|
||||
assert_eq!(value["models"]["bulk"], "deepseek-v4-flash");
|
||||
@@ -865,4 +1211,344 @@ mod tests {
|
||||
assert_eq!(lineup.picks[0].summary.as_deref(), Some("An abstract."));
|
||||
assert!(lineup.picks[1..].iter().all(|p| p.summary.is_none()));
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::curate::embedding::{EmbeddingClient, EmbeddingService, MockBackend};
|
||||
use crate::types::{Entry, ExtractMethod, SourceKind, SourceRef};
|
||||
use sqlx::Row as _;
|
||||
|
||||
fn now() -> Timestamp {
|
||||
"2026-09-02T09:00:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
fn run_date() -> Date {
|
||||
"2026-09-02".parse().unwrap()
|
||||
}
|
||||
|
||||
fn fixture_article(entry_id: i64, host: &str, words: usize) -> Article {
|
||||
let url = format!("https://{host}/post-{entry_id}");
|
||||
let body = (0..words)
|
||||
.map(|i| format!("word{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Article {
|
||||
id: 0,
|
||||
canonical_url: url.clone(),
|
||||
title: format!("Post {entry_id}"),
|
||||
best_entry_id: entry_id,
|
||||
content_html: format!("<p>{body}</p>"),
|
||||
word_count: words as i64,
|
||||
excerpt_only: false,
|
||||
image_count: 0,
|
||||
sources: vec![SourceRef {
|
||||
entry_id,
|
||||
feed_id: 100 + entry_id,
|
||||
feed_title: format!("Feed {entry_id}"),
|
||||
category: None,
|
||||
kind: SourceKind::Feed,
|
||||
}],
|
||||
first_seen: now(),
|
||||
url,
|
||||
author: None,
|
||||
feed_id: 100 + entry_id,
|
||||
feed_title: format!("Feed {entry_id}"),
|
||||
category: None,
|
||||
published_at: None,
|
||||
comments_url: None,
|
||||
image_urls: vec![],
|
||||
social: vec![],
|
||||
extract_method: ExtractMethod::Miniflux,
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_for(article: &Article) -> Entry {
|
||||
Entry {
|
||||
id: article.best_entry_id,
|
||||
feed_id: article.feed_id,
|
||||
feed_title: Some(article.feed_title.clone()),
|
||||
category: None,
|
||||
title: article.title.clone(),
|
||||
url: article.url.clone(),
|
||||
canonical_url: Some(article.canonical_url.clone()),
|
||||
author: None,
|
||||
published_at: None,
|
||||
comments_url: None,
|
||||
raw_content: article.content_html.clone(),
|
||||
fetched_at: now(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
_dir: tempfile::TempDir,
|
||||
db: Db,
|
||||
config: Config,
|
||||
articles: Vec<Article>,
|
||||
run_id: i64,
|
||||
}
|
||||
|
||||
/// Four articles: two ordinary, one on a blocked host, one published yesterday.
|
||||
async fn harness() -> Harness {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open_and_migrate(&dir.path().join("run.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = Config::default();
|
||||
config.curation.blocked_domains = vec!["blocked.example".into()];
|
||||
config.voyage.output_dimension = 4;
|
||||
config.target_article_count = 1;
|
||||
config.interests_opml = dir.path().join("interests.opml");
|
||||
std::fs::write(
|
||||
&config.interests_opml,
|
||||
"<opml><body><outline text=\"Writerdeck\"/></body></opml>",
|
||||
)
|
||||
.unwrap();
|
||||
config.profile_path = dir.path().join("profile.md");
|
||||
std::fs::write(&config.profile_path, "# Reader profile\n").unwrap();
|
||||
|
||||
let mut articles = vec![
|
||||
fixture_article(1, "a.example", 1200),
|
||||
fixture_article(2, "b.example", 900),
|
||||
fixture_article(3, "blocked.example", 1500),
|
||||
fixture_article(4, "d.example", 1400),
|
||||
];
|
||||
let entries = articles.iter().map(entry_for).collect::<Vec<_>>();
|
||||
db.upsert_entries(&entries).await.unwrap();
|
||||
persist_articles(&db, &mut articles).await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO issues (date, issue_number, generated_at)
|
||||
VALUES ('2026-09-01', 1, '2026-09-01T12:00:00Z')",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_articles (issue_date, article_id, section)
|
||||
VALUES ('2026-09-01', ?, 'Top Stories')",
|
||||
)
|
||||
.bind(articles[3].id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let run_id = db.start_run(run_date(), now()).await.unwrap();
|
||||
Harness {
|
||||
_dir: dir,
|
||||
db,
|
||||
config,
|
||||
articles,
|
||||
run_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn context<'a>(h: &'a Harness, skip_embeddings: bool) -> StageContext<'a> {
|
||||
StageContext {
|
||||
config: &h.config,
|
||||
db: &h.db,
|
||||
run_id: h.run_id,
|
||||
date: run_date(),
|
||||
soft_target: h.config.target_article_count,
|
||||
hard_max: h.config.curation.max_article_count,
|
||||
started_at: now(),
|
||||
out_dir: PathBuf::from("."),
|
||||
dry_run: true,
|
||||
skip_llm: true,
|
||||
skip_embeddings,
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_service(h: &Harness, backend: Arc<MockBackend>) -> EmbeddingService {
|
||||
let client = EmbeddingClient::with_backend(h.config.voyage.clone(), backend);
|
||||
EmbeddingService::with_client(h.db.clone(), h.config.voyage.clone(), client)
|
||||
}
|
||||
|
||||
async fn stage_rows(
|
||||
db: &Db,
|
||||
run_id: i64,
|
||||
) -> BTreeMap<i64, (String, Option<String>, Option<String>)> {
|
||||
sqlx::query(
|
||||
"SELECT article_id, stage, excluded_reason, admitted_by FROM candidate_runs
|
||||
WHERE run_id = ? ORDER BY article_id",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>("article_id"),
|
||||
(
|
||||
row.get::<String, _>("stage"),
|
||||
row.get::<Option<String>, _>("excluded_reason"),
|
||||
row.get::<Option<String>, _>("admitted_by"),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mocked_run_writes_a_candidate_runs_row_for_every_considered_article() {
|
||||
let h = harness().await;
|
||||
let ctx = context(&h, false);
|
||||
let backend = Arc::new(MockBackend::auto(4));
|
||||
let service = mock_service(&h, backend.clone());
|
||||
let mut report = RunReport::new(run_date(), now());
|
||||
|
||||
let features = prepare_features(&ctx, &h.articles, &service, &mut report).await;
|
||||
let [a, b, blocked, published] = [
|
||||
h.articles[0].id,
|
||||
h.articles[1].id,
|
||||
h.articles[2].id,
|
||||
h.articles[3].id,
|
||||
];
|
||||
assert_eq!(
|
||||
features.keys().copied().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([a, b])
|
||||
);
|
||||
assert_eq!(report.counts.eligible, 2);
|
||||
assert_eq!(report.counts.embedded, 2);
|
||||
assert_eq!(report.counts.rated_with_embeddings, 0);
|
||||
assert!(report.timings.0.contains_key("embed") && report.timings.0.contains_key("signals"));
|
||||
assert!(report.voyage_tokens > 0);
|
||||
// One batch for the two articles, one for the interest.
|
||||
assert_eq!(backend.calls(), 2);
|
||||
let signals = &features[&a].signals;
|
||||
assert!(signals.heuristic.is_some());
|
||||
assert!(
|
||||
signals.interest.is_some(),
|
||||
"interest present under the raw fallback"
|
||||
);
|
||||
assert!(
|
||||
signals.knn.is_none() && signals.feed.is_none(),
|
||||
"gates closed"
|
||||
);
|
||||
assert!(signals.preliminary.is_some());
|
||||
|
||||
let rows = stage_rows(&h.db, h.run_id).await;
|
||||
assert_eq!(rows.len(), 4, "one row per considered article");
|
||||
assert_eq!(rows[&blocked].0, "excluded");
|
||||
assert_eq!(rows[&blocked].1.as_deref(), Some("blocked"));
|
||||
assert_eq!(rows[&published].0, "excluded");
|
||||
assert_eq!(rows[&published].1.as_deref(), Some("published_before"));
|
||||
assert_eq!(rows[&a].0, "eligible");
|
||||
assert_eq!(rows[&a].1, None);
|
||||
let thin: String =
|
||||
sqlx::query_scalar("SELECT signals_json FROM candidate_runs WHERE article_id = ?")
|
||||
.bind(blocked)
|
||||
.fetch_one(h.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(thin, "{}");
|
||||
|
||||
// The old prefilter and selector, with the stage transitions of step 3.
|
||||
let curator = Curator::new(h.config.clone(), h.db.clone(), Llms::default());
|
||||
let candidates = curator
|
||||
.prefilter(h.articles.clone(), run_date())
|
||||
.await
|
||||
.unwrap();
|
||||
let admitted = candidates.iter().map(|c| c.article.id).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
admitted.iter().copied().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([a, b])
|
||||
);
|
||||
record_stage(&ctx, &features, &admitted, "admitted", None)
|
||||
.await
|
||||
.unwrap();
|
||||
record_stage(&ctx, &features, &admitted, "shortlisted", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let lineup = curator.select(candidates, run_date()).await.unwrap();
|
||||
let selected = lineup
|
||||
.picks
|
||||
.iter()
|
||||
.map(|p| p.article.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(selected.len(), 1);
|
||||
let not_selected = admitted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !selected.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
record_stage(&ctx, &features, &selected, "selected", None)
|
||||
.await
|
||||
.unwrap();
|
||||
record_stage(
|
||||
&ctx,
|
||||
&features,
|
||||
¬_selected,
|
||||
"shortlisted",
|
||||
Some("not_selected"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows = stage_rows(&h.db, h.run_id).await;
|
||||
assert_eq!(rows.len(), 4);
|
||||
let (winner, loser) = (selected[0], not_selected[0]);
|
||||
assert_eq!(
|
||||
rows[&winner],
|
||||
("selected".into(), None, Some("[\"prefilter\"]".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
rows[&loser],
|
||||
(
|
||||
"shortlisted".into(),
|
||||
Some("not_selected".into()),
|
||||
Some("[\"prefilter\"]".into())
|
||||
)
|
||||
);
|
||||
let text = telemetry::explain(
|
||||
&h.db,
|
||||
run_date(),
|
||||
Some(h.run_id),
|
||||
&telemetry::ExplainTarget::Article(loser),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
text.contains("stage: shortlisted · reason: not_selected"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_embeddings_makes_zero_voyage_calls_and_uses_the_cache() {
|
||||
let h = harness().await;
|
||||
let ctx = context(&h, true);
|
||||
let mut report = RunReport::new(run_date(), now());
|
||||
let service = build_embedding_service(&ctx, &mut report);
|
||||
assert!(!service.has_client(), "--skip-embeddings is cache-only");
|
||||
assert!(service.meter().is_none());
|
||||
let features = prepare_features(&ctx, &h.articles, &service, &mut report).await;
|
||||
assert_eq!(features.len(), 2);
|
||||
assert_eq!(report.counts.embedded, 0, "nothing cached yet");
|
||||
assert!(features.values().all(|f| f.signals.interest.is_none()));
|
||||
assert!(features.values().all(|f| f.signals.heuristic.is_some()));
|
||||
assert_eq!(report.voyage_tokens, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_voyage_failure_degrades_to_absent_signals_and_the_run_continues() {
|
||||
let h = harness().await;
|
||||
let ctx = context(&h, false);
|
||||
let backend = Arc::new(MockBackend::new()); // nothing scripted: every call fails
|
||||
let service = mock_service(&h, backend.clone());
|
||||
let mut report = RunReport::new(run_date(), now());
|
||||
let features = prepare_features(&ctx, &h.articles, &service, &mut report).await;
|
||||
assert!(backend.calls() >= 1);
|
||||
assert_eq!(features.len(), 2);
|
||||
assert_eq!(report.counts.eligible, 2);
|
||||
assert_eq!(report.counts.embedded, 0);
|
||||
assert!(report.error.is_none());
|
||||
for feature in features.values() {
|
||||
assert!(feature.signals.interest.is_none() && feature.signals.knn.is_none());
|
||||
assert!(feature.signals.heuristic.is_some());
|
||||
assert!(
|
||||
feature.signals.preliminary.is_some(),
|
||||
"scored on what is present"
|
||||
);
|
||||
}
|
||||
assert_eq!(stage_rows(&h.db, h.run_id).await.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user