Break up the image and EPUB god files

The image fixes left two problems of navigation. Image handling was
spread across `extract.rs` (300 lines of normalization) and
`epub/images.rs` (download, re-encode, markup rewriting, plus generic
HTML helpers that comments, world and x4 were all reaching into a
module named "images" to borrow). And `epub/build.rs` had grown to
1,148 lines of code covering cover rendering, ten askama templates,
every chapter renderer and the zip assembly.

New homes:

- `src/html.rs` — markup helpers that do not care what the markup is
  about: tag scanning, attribute parsing, entity decoding, escaping,
  XHTML fixups, reading a fragment as text. Previously scattered between
  `epub/images.rs` and `extract.rs`.
- `src/images/` — one module per stage of an article's images, in the
  order they run: `normalize` (make `<img>` usable, pre-readability),
  `refs` (what an article references), `fetch` + `encode` (download and
  re-encode per edition), `embed` (point the markup at what shipped).
- `src/epub/{cover,chapters,build}.rs` — the cover, the chapter
  renderers, and the ordering plus assembly that puts them together.
  `epub/fixtures.rs` takes the shared test issue, which was a public
  module wedged inside `build.rs`.
- `src/curate/profile/themes.rs` — a 260-line keyword table that sat in
  the middle of the profile logic.

`curate::html_to_text` is renamed `prompt_text`: it is a different
function from `html::html_to_text` (collapses whitespace, no DOM, sized
for prompt budgets) and sharing a name with it was a trap.

Largest module drops from 1,148 code lines to 828, and no file mixes
two subjects. Behaviour is unchanged: 231 lib tests plus 25 integration
tests green, and the real-world audit over issues 1–3 still reports 214
images referenced, 214 shown, 0 placeholders, 0 orphaned assets.

`image_audit` gains `--epub-out DIR`, which writes a readable EPUB of
the audited articles so images can be checked on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 03:35:21 +00:00
co-authored by Claude Opus 5
parent 254eaeb713
commit db19d08257
27 changed files with 3513 additions and 3132 deletions
+801
View File
@@ -0,0 +1,801 @@
//! Taste profile construction (spec §3.6, §3.9).
//!
//! A ~600-word document assembled from (a) the interest names parsed out of
//! `data/scour-interests.opml`, grouped into themes, (b) hard-coded stated
//! preferences, and (c) a "learned adjustments" section regenerated weekly from
//! recent 👍/👎 ratings. Stored and versioned in `kv`.
//!
//! This document is the **system prompt** for every DeepSeek call in the run, so
//! it must be byte-identical between requests: DeepSeek's automatic prefix cache
//! is what makes the whole pipeline cost cents rather than dollars (§3.6).
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::Path;
use anyhow::Context as _;
use jiff::Timestamp;
use jiff::civil::Date;
use serde::{Deserialize, Serialize};
use sqlx::Row as _;
use super::llm::LlmClient;
use crate::db::{Db, KV_PROFILE_VERSION, KV_TASTE_PROFILE};
use crate::types::{TasteProfile, Vote};
/// Rebuild cadence for the learned-adjustments section (§3.6).
pub const REBUILD_INTERVAL_DAYS: i64 = 7;
/// Ratings lookback used when rewriting learned adjustments (§3.9).
pub const RATINGS_LOOKBACK_DAYS: i64 = 90;
/// `kv` key holding just the learned-adjustments block, so that re-parsing the
/// OPML never loses what the ratings taught us (§3.6).
pub const KV_LEARNED_ADJUSTMENTS: &str = "taste_profile_learned";
/// Ratings fed to one rebuild call.
const MAX_RATINGS_IN_PROMPT: usize = 400;
/// Hard-coded stated preferences from the reader profile (spec §1).
pub const STATED_PREFERENCES: &str = "\
Prefers long-form, high-effort, well-written articles on any topic. Uses social \
proof (HN/Reddit/Lobsters upvotes and comment counts) as a quality proxy. Wants \
tech news, light general/US world news (Wikipedia Current Events style, neutral), \
Boston-area news, and ultra-niche community news.";
/// Placeholder used until the first ratings arrive (§3.6c).
pub const NO_LEARNED_ADJUSTMENTS: &str = "No reader ratings have been collected yet. Judge purely on the stated \
preferences and interests above.";
// ---------------------------------------------------------------------------
// OPML parsing (§3.6a)
// ---------------------------------------------------------------------------
/// Parse interest names out of the Scour OPML (§3.6).
///
/// The file is one long line of `<outline type="rss" text="Rust" …/>` elements;
/// we take every `text` attribute, XML-unescape it, trim it, and de-duplicate
/// case-insensitively (the export contains both `Self-hosting` and
/// `Self-Hosting`). Order follows the document so the result is deterministic.
pub fn parse_interests(opml_path: &Path) -> anyhow::Result<Vec<String>> {
let raw = std::fs::read_to_string(opml_path)
.with_context(|| format!("reading the interests OPML at {}", opml_path.display()))?;
let interests = parse_interests_str(&raw);
if interests.is_empty() {
anyhow::bail!(
"no <outline text=\"\"> interests found in {}",
opml_path.display()
);
}
tracing::debug!(count = interests.len(), "parsed scour interests");
Ok(interests)
}
/// [`parse_interests`] over an in-memory document (also the unit-test seam).
pub fn parse_interests_str(raw: &str) -> Vec<String> {
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out = Vec::new();
for chunk in raw.split("text=\"").skip(1) {
let Some((value, _)) = chunk.split_once('"') else {
continue;
};
let name = xml_unescape(value).trim().to_string();
if name.is_empty() {
continue;
}
if seen.insert(name.to_lowercase()) {
out.push(name);
}
}
out
}
fn xml_unescape(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
s.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&amp;", "&")
}
pub mod themes;
pub use themes::group_into_themes;
// ---------------------------------------------------------------------------
// Document assembly (§3.6)
// ---------------------------------------------------------------------------
/// The invariant part of the profile: who the reader is and how to judge for him.
/// Kept as one constant so the prompt bytes never drift between calls (§3.6).
const PROFILE_PREAMBLE: &str = "\
You are the editor-in-chief of *The Daily EPUB*, a personal morning newspaper \
assembled every day for exactly one reader. Everything you are asked to do — \
score, select, place, summarize, introduce — serves his taste, not a general \
audience's. When a judgement call is close, re-read this profile and decide the \
way he would.
## The reader
A software engineer in the Boston area who reads on e-ink in the morning. He \
would rather read six excellent long pieces than thirty adequate short ones. He \
reads across an unusually wide range of subjects and does not need a topic to be \
professionally useful to enjoy it.
## What he wants
- **Long-form and high-effort above all.** Essays, deep dives, post-mortems, \
field notes, annotated experiments, thorough explainers, personal narratives with \
real specificity. Length is a proxy, not the goal: what he is buying is evident \
effort and a point of view.
- **Any topic, if the writing is excellent.** A brilliant piece on medieval \
bookbinding beats a competent one on his favourite language. Do not reject \
something merely because it sits outside the interest list below.
- **Social proof as a quality signal, not a ranking.** Hundreds of HN or Reddit \
points and a busy comment thread mean the piece survived contact with a critical \
audience — treat it as evidence, then judge the writing yourself. A quiet post \
from a good blog can outrank a viral one.
- **Boston and New England local news** — city government, transit, universities, \
neighbourhood and civic stories.
- **Ultra-niche community news.** Small scenes with their own vocabulary — a \
mailing-list argument, a hobby project's release story, a subculture's internal \
debate — are a feature of this paper, not a distraction.
- **World and US news kept light and neutral.** Wikipedia-Current-Events register: \
what happened, who is involved, no outrage, no opinion columns. The World \
Briefing section is compiled separately; do not fill the paper with wire copy.
## What he does not want
Press releases and funding announcements dressed as news; SEO listicles; \
link-roundup and \"this week in X\" posts; changelogs and release notes without \
analysis; sponsored content and thinly disguised marketing; crypto and \
engagement-bait; rewrites of a story he can read at the source; culture-war \
outrage; anything whose substance is one paragraph stretched to five.
## How to judge
Ask: *would he still be glad he read this an hour later?* Reward specificity, \
first-hand experience, honest uncertainty, and prose with a human behind it. \
Penalize padding, unsourced confidence, and summaries of other people's work. \
Prefer the primary source over the aggregator when both are present.";
/// Assemble the full profile document from interests, stated preferences and the
/// current learned-adjustments block (§3.6).
///
/// Pure and deterministic: the same inputs always produce the same bytes.
pub fn build(interests: &[String], learned_adjustments: &str) -> String {
let mut doc = String::with_capacity(8 * 1024);
doc.push_str("# The Daily EPUB — reader taste profile\n\n");
doc.push_str(PROFILE_PREAMBLE);
doc.push_str("\n\n## Stated preferences (verbatim)\n\n");
doc.push_str(STATED_PREFERENCES);
doc.push_str("\n\n## Standing interests\n\n");
doc.push_str(
"These are his ~220 subscribed interest topics, grouped. They raise the \
floor for a match, but never cap the paper: an outstanding article on \
none of these still belongs.\n\n",
);
for (theme, members) in group_into_themes(interests) {
let _ = writeln!(doc, "- **{}**: {}", theme, members.join(", "));
}
doc.push_str("\n## Learned adjustments (rebuilt weekly from 👍/👎 ratings)\n\n");
let learned = learned_adjustments.trim();
doc.push_str(if learned.is_empty() {
NO_LEARNED_ADJUSTMENTS
} else {
learned
});
doc.push('\n');
doc
}
// ---------------------------------------------------------------------------
// Persistence (§3.6 `kv`)
// ---------------------------------------------------------------------------
/// `kv[profile_version]` payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProfileVersion {
version: i64,
built_at: String,
}
async fn stored_version(db: &Db) -> anyhow::Result<Option<(i64, Timestamp)>> {
let Some(raw) = db.kv_get(KV_PROFILE_VERSION).await? else {
return Ok(None);
};
match serde_json::from_str::<ProfileVersion>(&raw) {
Ok(v) => {
let built = v.built_at.parse::<Timestamp>().unwrap_or_else(|_| {
tracing::warn!(value = %v.built_at, "unparseable profile build time");
Timestamp::UNIX_EPOCH
});
Ok(Some((v.version, built)))
}
Err(e) => {
tracing::warn!(error = %e, "unparseable kv[profile_version]; treating as absent");
Ok(None)
}
}
}
async fn store(db: &Db, profile: &TasteProfile, learned: &str) -> anyhow::Result<()> {
db.kv_set(KV_TASTE_PROFILE, &profile.text).await?;
db.kv_set(KV_LEARNED_ADJUSTMENTS, learned).await?;
let version = serde_json::to_string(&ProfileVersion {
version: profile.version,
built_at: profile.built_at.to_string(),
})?;
db.kv_set(KV_PROFILE_VERSION, &version).await?;
Ok(())
}
/// Load the stored profile, building a default one on first run (§3.6).
pub async fn load_or_build(db: &Db, opml_path: &Path) -> anyhow::Result<TasteProfile> {
if let Some(text) = db.kv_get(KV_TASTE_PROFILE).await?.filter(|t| !t.is_empty()) {
let (version, built_at) = stored_version(db).await?.unwrap_or((1, Timestamp::now()));
tracing::debug!(version, chars = text.len(), "loaded stored taste profile");
return Ok(TasteProfile {
text,
version,
built_at,
});
}
let interests = parse_interests(opml_path)?;
let learned = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
let profile = TasteProfile {
text: build(&interests, &learned),
version: 1,
built_at: Timestamp::now(),
};
store(db, &profile, &learned).await?;
tracing::info!(
interests = interests.len(),
chars = profile.text.len(),
"built the initial taste profile"
);
Ok(profile)
}
/// True when the stored profile is older than [`REBUILD_INTERVAL_DAYS`] (§3.6).
pub async fn is_stale(db: &Db) -> anyhow::Result<bool> {
let Some((_, built_at)) = stored_version(db).await? else {
return Ok(true);
};
let age_days = (Timestamp::now().as_second() - built_at.as_second()) / 86_400;
Ok(age_days >= REBUILD_INTERVAL_DAYS)
}
/// The automatic weekly rebuild the pipeline calls before curating (§3.6).
///
/// Rebuilds only when the stored profile is at least a week old *and* there are
/// ratings to learn from; otherwise returns the profile unchanged. Failures are
/// non-fatal — a stale profile still curates fine.
pub async fn weekly_rebuild_if_due(
db: &Db,
llm: &LlmClient,
opml_path: &Path,
) -> anyhow::Result<Option<TasteProfile>> {
if !is_stale(db).await? {
return Ok(None);
}
if recent_ratings(db).await?.is_empty() {
tracing::debug!("profile is stale but there are no ratings to learn from");
return Ok(None);
}
tracing::info!("taste profile is over a week old; rebuilding learned adjustments");
Ok(Some(rebuild(db, llm, opml_path).await?))
}
// ---------------------------------------------------------------------------
// Rebuild (§3.6c, §3.9)
// ---------------------------------------------------------------------------
/// A rated article as fed to the learned-adjustments prompt (§3.6c).
#[derive(Debug, Clone, PartialEq)]
pub struct RatedArticle {
pub vote: Vote,
pub title: String,
pub feed_title: String,
pub category: String,
/// The stage-A category the model itself assigned, when we have one.
pub llm_category: String,
}
/// Recent ratings joined to article titles, feeds and categories (§3.6c).
///
/// `db.rs` exposes `recent_ratings_detailed`, but it returns titles only; the
/// prompt is much more useful with the feed and category attached.
pub async fn recent_ratings(db: &Db) -> anyhow::Result<Vec<RatedArticle>> {
// Timestamp arithmetic only accepts uniform units, so days become hours.
let since = Timestamp::now()
.checked_sub(jiff::Span::new().hours(RATINGS_LOOKBACK_DAYS * 24))
.unwrap_or(Timestamp::UNIX_EPOCH);
let since_date = since.to_zoned(jiff::tz::TimeZone::UTC).date();
let rows = sqlx::query(
"SELECT r.vote AS vote,
COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title,
COALESCE(e.category, '') AS category,
COALESCE((SELECT s.llm_category FROM scores s
WHERE s.article_id = a.id AND s.llm_category IS NOT NULL
ORDER BY s.run_date DESC LIMIT 1), '') AS llm_category
FROM ratings r
JOIN articles a ON a.id = r.article_id
LEFT JOIN entries e ON e.id = a.best_entry_id
WHERE r.issue_date >= ?
ORDER BY r.rated_at DESC
LIMIT ?",
)
.bind(since_date.to_string())
.bind(MAX_RATINGS_IN_PROMPT as i64)
.fetch_all(db.pool())
.await
.context("loading recent ratings for the profile rebuild")?;
Ok(rows
.iter()
.map(|r| RatedArticle {
vote: if r.get::<i64, _>("vote") >= 0 {
Vote::Up
} else {
Vote::Down
},
title: r.get("title"),
feed_title: r.get("feed_title"),
category: r.get("category"),
llm_category: r.get("llm_category"),
})
.collect())
}
/// Instruction block for the weekly learned-adjustments rewrite (§3.6c).
pub const LEARNED_ADJUSTMENTS_PROMPT: &str = "\
TASK: rewrite the \"Learned adjustments\" section of the reader profile in your \
system prompt, using only the rating history below.
Each line is a thumbs-up or thumbs-down the reader gave an article that appeared \
in a past issue, with the article's title, the feed it came from, and its \
category.
Look for patterns, not one-offs. Good adjustments name a *kind* of article and a \
*reason*: \"consistently downvotes vendor engineering-blog posts that are really \
product announcements\"; \"consistently upvotes database-internals deep dives, \
even very long ones\"; \"lukewarm on AI-industry news, warm on hands-on LLM \
tinkering\". Ignore patterns supported by fewer than two ratings, and never \
contradict the stated preferences — refine them.
Write 120200 words as 48 bullet points, each one imperative and usable while \
scoring (\"Rank X higher\", \"Be sceptical of Y\"). Do not mention specific \
article titles, the rating counts, or this instruction. If the history is too \
thin to support any pattern, say so in one sentence instead of inventing one.
Return JSON exactly: {\"learned_adjustments\": \"<the bullet points, as markdown>\"}
RATING HISTORY (newest first):
";
/// The weekly rewrite's JSON envelope.
#[derive(Debug, Clone, Deserialize)]
struct LearnedAdjustmentsResponse {
#[serde(default)]
learned_adjustments: String,
}
/// Render the rating history block of the rebuild prompt (§3.6c).
pub fn build_rebuild_prompt(ratings: &[RatedArticle]) -> String {
let mut prompt = String::from(LEARNED_ADJUSTMENTS_PROMPT);
let (mut up, mut down) = (0usize, 0usize);
for r in ratings {
match r.vote {
Vote::Up => up += 1,
Vote::Down => down += 1,
}
let category = if r.llm_category.is_empty() {
r.category.as_str()
} else {
r.llm_category.as_str()
};
let _ = writeln!(
prompt,
"{} | {} | feed: {} | category: {}",
match r.vote {
Vote::Up => "UP ",
Vote::Down => "DOWN",
},
r.title.trim(),
if r.feed_title.is_empty() {
"unknown"
} else {
r.feed_title.trim()
},
if category.is_empty() {
"unknown"
} else {
category.trim()
},
);
}
let _ = write!(prompt, "\n({up} up, {down} down)\n");
prompt
}
/// `daily-epub profile rebuild` — summarize recent ratings into a new learned
/// adjustments section and store a new profile version (§3.6, §3.9).
pub async fn rebuild(db: &Db, llm: &LlmClient, opml_path: &Path) -> anyhow::Result<TasteProfile> {
let interests = parse_interests(opml_path)?;
let ratings = recent_ratings(db).await?;
let previous = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
let learned = if ratings.is_empty() {
tracing::info!("no ratings in the lookback window; keeping the existing adjustments");
previous
} else {
let prompt = build_rebuild_prompt(&ratings);
match llm
.complete_json::<LearnedAdjustmentsResponse>(&prompt, 0.4)
.await
{
Ok(resp) if !resp.learned_adjustments.trim().is_empty() => {
tracing::info!(
ratings = ratings.len(),
chars = resp.learned_adjustments.len(),
"rewrote the learned-adjustments section"
);
resp.learned_adjustments.trim().to_string()
}
Ok(_) => {
tracing::warn!("the model returned empty adjustments; keeping the previous ones");
previous
}
Err(e) => {
tracing::warn!(error = %e, "learned-adjustments rewrite failed; keeping the previous ones");
previous
}
}
};
let next_version = stored_version(db).await?.map_or(1, |(v, _)| v + 1);
let profile = TasteProfile {
text: build(&interests, &learned),
version: next_version,
built_at: Timestamp::now(),
};
store(db, &profile, &learned).await?;
tracing::info!(
version = profile.version,
chars = profile.text.len(),
"stored a new taste profile"
);
Ok(profile)
}
// ---------------------------------------------------------------------------
// Feed priors (§3.9a)
// ---------------------------------------------------------------------------
/// Recompute per-feed beta-smoothed priors from the ratings table (§3.9).
///
/// Returns the number of feeds written. `FeedPrior::rate()` does the smoothing;
/// this only maintains the raw counts plus how often the feed has been included.
pub async fn rebuild_feed_priors(db: &Db) -> anyhow::Result<usize> {
use std::collections::HashMap;
use crate::types::{FeedId, FeedPrior};
let mut priors: HashMap<FeedId, FeedPrior> = HashMap::new();
for (feed_id, vote) in db.ratings_with_feed().await? {
let entry = priors.entry(feed_id).or_insert(FeedPrior {
feed_id,
..FeedPrior::default()
});
match vote {
Vote::Up => entry.upvotes += 1,
Vote::Down => entry.downvotes += 1,
}
}
let rows = sqlx::query(
"SELECT e.feed_id AS feed_id, COUNT(*) AS included
FROM issue_articles ia
JOIN articles a ON a.id = ia.article_id
JOIN entries e ON e.id = a.best_entry_id
GROUP BY e.feed_id",
)
.fetch_all(db.pool())
.await
.context("counting per-feed inclusions")?;
for row in &rows {
let feed_id: FeedId = row.get("feed_id");
let included: i64 = row.get("included");
priors
.entry(feed_id)
.or_insert(FeedPrior {
feed_id,
..FeedPrior::default()
})
.included = included;
}
for prior in priors.values() {
db.upsert_feed_prior(prior).await?;
}
tracing::info!(feeds = priors.len(), "rebuilt feed priors");
Ok(priors.len())
}
/// Convenience for callers that only have a [`Date`]: the ratings lookback start.
pub fn ratings_since(today: Date) -> Date {
today
.checked_sub(jiff::Span::new().days(RATINGS_LOOKBACK_DAYS))
.unwrap_or(today)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Rating;
const OPML_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/data/scour-interests.opml");
fn interests() -> Vec<String> {
parse_interests(Path::new(OPML_PATH)).expect("the shipped OPML parses")
}
#[test]
fn parses_the_shipped_opml() {
let list = interests();
assert!(
list.len() > 180,
"expected ~220 interests, got {}",
list.len()
);
assert!(list.iter().any(|i| i == "Rust"));
assert!(list.iter().any(|i| i == "Boston Tech"));
assert!(list.iter().any(|i| i == "E-Ink Displays"));
// Trailing whitespace is trimmed and case-duplicates collapse.
assert!(list.iter().any(|i| i == "photography"));
let lowered: Vec<String> = list.iter().map(|i| i.to_lowercase()).collect();
let unique: BTreeSet<&String> = lowered.iter().collect();
assert_eq!(unique.len(), lowered.len(), "duplicates survived");
assert!(!list.iter().any(|i| i.contains("scour.ing")));
}
#[test]
fn parsing_handles_entities_and_empties() {
let raw = r#"<opml><body>
<outline text="Tea &amp; Coffee" xmlUrl="x?a=1&amp;b=2"/>
<outline text=" Rust "/>
<outline text="rust"/>
<outline text=""/>
</body></opml>"#;
assert_eq!(
parse_interests_str(raw),
vec!["Tea & Coffee".to_string(), "Rust".to_string()]
);
}
#[test]
fn profile_document_is_deterministic_and_complete() {
let list = interests();
let one = build(&list, "");
let two = build(&list, "");
assert_eq!(one, two, "profile assembly must be byte-stable");
assert_eq!(one.as_bytes(), two.as_bytes());
assert!(one.contains(STATED_PREFERENCES));
assert!(one.contains(NO_LEARNED_ADJUSTMENTS));
assert!(one.contains("Boston"));
assert!(one.contains("Wikipedia-Current-Events"));
assert!(one.contains("Rust"));
assert!(one.contains("ultra-niche") || one.contains("Ultra-niche"));
// A real document, not a stub, but not a novel either.
let words = one.split_whitespace().count();
assert!((500..3000).contains(&words), "profile is {words} words");
let learned = build(&list, "- Rank database internals higher.");
assert!(learned.contains("- Rank database internals higher."));
assert!(!learned.contains(NO_LEARNED_ADJUSTMENTS));
}
async fn temp_db() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().expect("tempdir");
let db = Db::open_and_migrate(&dir.path().join("profile.db"))
.await
.expect("db");
(dir, db)
}
#[tokio::test]
async fn load_or_build_persists_and_reuses() {
let (_dir, db) = temp_db().await;
let first = load_or_build(&db, Path::new(OPML_PATH))
.await
.expect("first build");
assert_eq!(first.version, 1);
assert!(!is_stale(&db).await.expect("staleness"));
let second = load_or_build(&db, Path::new(OPML_PATH))
.await
.expect("second load");
assert_eq!(first.text, second.text);
assert_eq!(second.version, 1);
assert_eq!(
db.kv_get(KV_TASTE_PROFILE).await.expect("kv").as_deref(),
Some(first.text.as_str())
);
}
#[tokio::test]
async fn missing_version_row_means_stale() {
let (_dir, db) = temp_db().await;
assert!(is_stale(&db).await.expect("staleness"));
db.kv_set(KV_PROFILE_VERSION, "not json")
.await
.expect("kv set");
assert!(is_stale(&db).await.expect("staleness"));
db.kv_set(
KV_PROFILE_VERSION,
r#"{"version":3,"built_at":"2000-01-01T00:00:00Z"}"#,
)
.await
.expect("kv set");
assert!(is_stale(&db).await.expect("staleness"));
}
#[tokio::test]
async fn rebuild_uses_the_model_and_bumps_the_version() {
use super::super::llm::{MockBackend, UsageMeter};
use crate::config::DeepseekConfig;
use std::sync::Arc;
let (_dir, db) = temp_db().await;
load_or_build(&db, Path::new(OPML_PATH))
.await
.expect("initial");
seed_rating(&db, 1, "Postgres index internals", Vote::Up).await;
seed_rating(&db, 2, "Series B funding announced", Vote::Down).await;
let backend = Arc::new(MockBackend::new());
backend.push(
r#"{"learned_adjustments": "- Rank database internals deep dives higher.\n- Be sceptical of funding announcements."}"#,
crate::types::TokenUsage::default(),
);
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend.clone(),
);
let rebuilt = rebuild(&db, &llm, Path::new(OPML_PATH))
.await
.expect("rebuild");
assert_eq!(rebuilt.version, 2);
assert!(
rebuilt
.text
.contains("Rank database internals deep dives higher.")
);
assert!(
rebuilt
.text
.contains("Be sceptical of funding announcements.")
);
// The rating history reached the prompt, with feed + category context.
let prompts = backend.prompts();
assert_eq!(prompts.len(), 1);
assert!(prompts[0].user.contains("Postgres index internals"));
assert!(
prompts[0]
.user
.contains("DOWN | Series B funding announced")
);
assert!(prompts[0].user.contains("(1 up, 1 down)"));
// A later reload sees the new document.
let loaded = load_or_build(&db, Path::new(OPML_PATH))
.await
.expect("reload");
assert_eq!(loaded.text, rebuilt.text);
assert_eq!(loaded.version, 2);
}
#[tokio::test]
async fn rebuild_without_ratings_skips_the_model() {
use super::super::llm::{MockBackend, UsageMeter};
use crate::config::DeepseekConfig;
use std::sync::Arc;
let (_dir, db) = temp_db().await;
let backend = Arc::new(MockBackend::new());
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), 2.0),
backend.clone(),
);
let profile = rebuild(&db, &llm, Path::new(OPML_PATH))
.await
.expect("rebuild");
assert_eq!(backend.calls(), 0, "no ratings ⇒ no LLM call");
assert!(profile.text.contains(NO_LEARNED_ADJUSTMENTS));
assert!(
weekly_rebuild_if_due(&db, &llm, Path::new(OPML_PATH))
.await
.expect("weekly")
.is_none()
);
}
#[tokio::test]
async fn feed_priors_are_recomputed_from_ratings() {
let (_dir, db) = temp_db().await;
seed_rating(&db, 1, "Good one", Vote::Up).await;
seed_rating(&db, 2, "Bad one", Vote::Down).await;
let feeds = rebuild_feed_priors(&db).await.expect("priors");
assert_eq!(feeds, 1);
let priors = db.feed_priors().await.expect("load");
assert_eq!(priors.len(), 1);
assert_eq!(priors[0].upvotes, 1);
assert_eq!(priors[0].downvotes, 1);
assert!((priors[0].rate() - 0.5).abs() < 1e-12);
}
/// Insert an entry + article + rating triple that the joins can see.
async fn seed_rating(db: &Db, id: i64, title: &str, vote: Vote) {
use crate::types::{Entry, ExtractMethod, SourceRef};
let ts: Timestamp = "2026-08-15T05:30:00Z".parse().expect("ts");
db.upsert_entry(&Entry {
id,
feed_id: 7,
feed_title: Some("A Feed".into()),
category: Some("Tech".into()),
title: title.into(),
url: format!("https://example.com/{id}"),
canonical_url: Some(format!("https://example.com/{id}")),
author: None,
published_at: Some(ts),
comments_url: None,
raw_content: String::new(),
fetched_at: ts,
})
.await
.expect("entry");
let article_id = db
.upsert_article(&crate::types::Article {
id: 0,
canonical_url: format!("https://example.com/{id}"),
title: title.into(),
best_entry_id: id,
content_html: String::new(),
word_count: 900,
excerpt_only: false,
image_count: 0,
sources: Vec::<SourceRef>::new(),
first_seen: ts,
url: format!("https://example.com/{id}"),
author: None,
feed_id: 7,
feed_title: "A Feed".into(),
category: Some("Tech".into()),
published_at: Some(ts),
comments_url: None,
image_urls: vec![],
social: vec![],
extract_method: ExtractMethod::Miniflux,
})
.await
.expect("article");
db.upsert_rating(&Rating {
issue_date: Timestamp::now().to_zoned(jiff::tz::TimeZone::UTC).date(),
article_id,
vote,
rated_at: Timestamp::now(),
})
.await
.expect("rating");
}
}