diff --git a/README.md b/README.md
index 1dd8dfb..03b5971 100644
--- a/README.md
+++ b/README.md
@@ -24,8 +24,8 @@ Steady-state cost is roughly **$0.05–0.30/day** in DeepSeek tokens, hard-cappe
```
Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
- ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ world briefing
- ─▶ editorial ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
+ ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial
+ ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
```
Every stage writes to SQLite, so a run is idempotent per date: re-running
@@ -380,7 +380,8 @@ from what you see in step 8.
| `Font path is required` for a settings file that *does* set `font.path` | The process cannot read the file, and the converter cannot tell that apart from the file not existing. Almost always running the converter as yourself instead of `daily-epub` (see above), or a font path that has moved. `sudo -u daily-epub cat /etc/daily-epub/xtc-settings.json` and `sudo -u daily-epub test -r && echo ok` settle it. |
| The X4's OPDS browser says "No entries found" | It fetched and parsed the feed but accepted no entry. Every acquisition link must be typed exactly `application/epub+zip`; anything else is dropped silently. `curl -s -u user:pass https://daily.hallada.net/opds/daily.xml \| grep -c ""` — zero means nothing has been published yet. |
| The X4's OPDS browser says "Failed to fetch feed" | The request never completed: wrong URL, TLS, or credentials. "Failed to parse feed" means malformed XML. The three messages are distinct — read which one you got. |
-| No World Briefing | The portal page for the issue's own date is an empty stub until midday UTC, so the run falls back up to `world::MAX_LOOKBACK_DAYS` days. A warning means even those were empty or Wikipedia was unreachable. |
+| No World Briefing | Retrieval begins with the previous calendar day (the latest completed page) and falls back up to `world::MAX_LOOKBACK_DAYS` days. A warning means those pages were empty or Wikipedia was unreachable. |
+| An X4 cover still shows an old black band after regeneration | CrossInk caches its generated home-screen thumbnail under the EPUB path. Delete that book's cache before retesting the same filename, reopen the EPUB, then return to the Minimal home screen. |
---
diff --git a/src/epub/build.rs b/src/epub/build.rs
index 88b5702..9793808 100644
--- a/src/epub/build.rs
+++ b/src/epub/build.rs
@@ -27,8 +27,19 @@ pub const CREATOR: &str = "The Daily EPUB";
pub const LANGUAGE: &str = "en";
/// Characters of the hex HMAC kept in rating links (§3.9).
pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN;
-/// Cover image path inside the EPUB.
-pub const COVER_HREF: &str = "cover.png";
+#[derive(Debug, Clone, PartialEq)]
+pub struct CoverAsset {
+ pub bytes: Vec,
+ pub filename: &'static str,
+ pub mime: &'static str,
+}
+
+pub fn cover_href(edition: Edition) -> &'static str {
+ match edition {
+ Edition::Standard => "cover.png",
+ Edition::X4 => "cover.jpg",
+ }
+}
/// One rendered chapter ready to be added to the EPUB (§3.10).
#[derive(Debug, Clone, PartialEq)]
@@ -103,6 +114,7 @@ struct CoverSvg {
struct CoverPage {
title: String,
alt: String,
+ cover_href: &'static str,
}
#[derive(Template)]
@@ -298,21 +310,27 @@ fn cover_svg(
Ok(tpl.render()?)
}
-/// Render the cover SVG and rasterize it with `resvg` + `tiny-skia`:
-/// 1200×1600 for `Standard`, 480×800 grayscale for `X4` (§3.10).
-pub fn render_cover(issue: &Issue, edition: Edition) -> Result, EpubError> {
+/// Render the standard cover as RGB PNG and the X4 cover as baseline RGB JPEG.
+pub fn render_cover(issue: &Issue, edition: Edition) -> Result {
let (width, height) = cover_size(edition);
- let grayscale = edition == Edition::X4;
let svg = cover_svg(issue, edition, width, height)?;
- match rasterize(&svg, width, height) {
- Some(pixmap) => encode_cover(pixmap, grayscale),
+ let pixmap = match rasterize(&svg, width, height) {
+ Some(pixmap) => pixmap,
None => {
tracing::warn!("no usable system fonts: falling back to a geometric cover");
- let pixmap = draw_fallback_cover(width, height, edition)
- .ok_or_else(|| EpubError::Build("could not allocate the cover".into()))?;
- encode_cover(pixmap, grayscale)
+ draw_fallback_cover(width, height, edition)
+ .ok_or_else(|| EpubError::Build("could not allocate the cover".into()))?
}
- }
+ };
+ Ok(CoverAsset {
+ bytes: encode_cover(pixmap, edition)?,
+ filename: cover_href(edition),
+ mime: if edition == Edition::X4 {
+ "image/jpeg"
+ } else {
+ "image/png"
+ },
+ })
}
fn rasterize(svg: &str, width: u32, height: u32) -> Option {
@@ -410,21 +428,22 @@ fn draw_fallback_cover(width: u32, height: u32, edition: Edition) -> Option Result, EpubError> {
+fn encode_cover(pixmap: tiny_skia::Pixmap, edition: Edition) -> Result, EpubError> {
let (w, h) = (pixmap.width(), pixmap.height());
let rgba = image::RgbaImage::from_raw(w, h, pixmap.take_demultiplied())
.ok_or_else(|| EpubError::Build("cover pixel buffer had the wrong size".into()))?;
- let dynamic = image::DynamicImage::ImageRgba8(rgba);
- let dynamic = if grayscale {
- image::DynamicImage::ImageLuma8(dynamic.to_luma8())
- } else {
- image::DynamicImage::ImageRgb8(dynamic.to_rgb8())
- };
- let mut out = std::io::Cursor::new(Vec::new());
- dynamic
- .write_to(&mut out, image::ImageFormat::Png)
- .map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?;
- Ok(out.into_inner())
+ let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
+ let mut bytes = Vec::new();
+ match edition {
+ Edition::Standard => image::DynamicImage::ImageRgb8(rgb).write_to(
+ &mut std::io::Cursor::new(&mut bytes),
+ image::ImageFormat::Png,
+ ),
+ Edition::X4 => image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 92)
+ .encode_image(&image::DynamicImage::ImageRgb8(rgb)),
+ }
+ .map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?;
+ Ok(bytes)
}
// ---------------------------------------------------------------------------
@@ -759,6 +778,7 @@ fn render_cover_page(issue: &Issue, edition: Edition) -> Result MetadataOpfV3 {
"dcterms:date",
&format!(
"{date}\n {date}\n \
+ {LANGUAGE}\n \
{date}"
),
None,
@@ -889,7 +910,7 @@ pub fn assemble(
edition: Edition,
chapters: &[Chapter],
images_: &[ImageAsset],
- cover_png: &[u8],
+ cover: &CoverAsset,
) -> Result, EpubError> {
let zip = ZipLibrary::new().map_err(|e| epub_err("zip library", e))?;
let mut builder = EpubBuilder::new(zip).map_err(|e| epub_err("epub builder", e))?;
@@ -900,9 +921,6 @@ pub fn assemble(
builder
.metadata("author", CREATOR)
.map_err(|e| epub_err("author metadata", e))?;
- builder
- .metadata("lang", LANGUAGE)
- .map_err(|e| epub_err("lang metadata", e))?;
builder
.metadata(
"generator",
@@ -948,7 +966,7 @@ pub fn assemble(
.stylesheet(stylesheet(edition).as_bytes())
.map_err(|e| epub_err("stylesheet", e))?;
builder
- .add_cover_image(COVER_HREF, cover_png, "image/png")
+ .add_cover_image(cover.filename, cover.bytes.as_slice(), cover.mime)
.map_err(|e| epub_err("cover image", e))?;
for asset in images_ {
builder
@@ -1104,7 +1122,17 @@ pub mod fixtures {
date: "2026-08-15".parse().expect("fixed date"),
source_url: "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_15"
.into(),
- body_html: "- Something happened somewhere.
".into(),
+ overview: Some("A concise view of the day.".into()),
+ sections: vec![WorldBriefingSection {
+ title: "Top Stories".into(),
+ events: vec![WorldEvent {
+ id: "s1-e1".into(),
+ source_text: "Something happened somewhere.".into(),
+ links: vec![],
+ children: vec![],
+ summary: Some("The event in context.".into()),
+ }],
+ }],
}),
colophon: Colophon {
model: "deepseek-v4-flash".into(),
@@ -1363,22 +1391,39 @@ mod tests {
fn covers_rasterize_for_both_editions() {
let issue = issue();
for edition in [Edition::Standard, Edition::X4] {
- let png = render_cover(&issue, edition).expect("cover");
- let decoded = image::load_from_memory(&png).expect("cover is a valid png");
+ let cover = render_cover(&issue, edition).expect("cover");
+ let decoded = image::load_from_memory(&cover.bytes).expect("cover is a valid image");
assert_eq!(
(decoded.width(), decoded.height()),
cover_size(edition),
"cover size for {edition:?}"
);
+ assert_eq!(decoded.color(), image::ColorType::Rgb8);
if edition == Edition::X4 {
- assert_eq!(decoded.color(), image::ColorType::L8);
+ assert_eq!(cover.filename, "cover.jpg");
+ assert_eq!(cover.mime, "image/jpeg");
+ assert!(
+ cover.bytes.windows(2).any(|marker| marker == [0xff, 0xc0]),
+ "baseline SOF0 missing"
+ );
+ assert!(
+ !cover.bytes.windows(2).any(|marker| marker == [0xff, 0xc2]),
+ "progressive SOF2 present"
+ );
+ } else {
+ assert_eq!(cover.filename, "cover.png");
+ assert_eq!(cover.mime, "image/png");
}
}
}
#[test]
fn fallback_cover_is_drawn_without_fonts() {
- let png = encode_cover(draw_fallback_cover(480, 800, Edition::X4).unwrap(), true).unwrap();
+ let png = encode_cover(
+ draw_fallback_cover(480, 800, Edition::X4).unwrap(),
+ Edition::X4,
+ )
+ .unwrap();
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!((decoded.width(), decoded.height()), (480, 800));
// Some ink actually landed on the page.
@@ -1426,8 +1471,8 @@ mod tests {
assert_eq!(standard.matches("fill=\"#ffffff\"").count(), 1);
// The badge sits between the stats line and the footer, inside the frame.
- let png = render_cover(&issue, Edition::X4).unwrap();
- let gray = image::load_from_memory(&png).unwrap().to_luma8();
+ let cover = render_cover(&issue, Edition::X4).unwrap();
+ let gray = image::load_from_memory(&cover.bytes).unwrap().to_luma8();
let dark_in_badge = (584..634)
.flat_map(|y| (144..336).map(move |x| (x, y)))
.filter(|&(x, y)| gray.get_pixel(x, y)[0] < 32)
diff --git a/src/epub/mod.rs b/src/epub/mod.rs
index 75fa8e4..24a88e5 100644
--- a/src/epub/mod.rs
+++ b/src/epub/mod.rs
@@ -182,7 +182,6 @@ mod tests {
"OEBPS/toc.ncx",
"OEBPS/nav.xhtml",
"OEBPS/stylesheet.css",
- "OEBPS/cover.png",
"OEBPS/cover.xhtml",
"OEBPS/front.xhtml",
"OEBPS/in-this-issue.xhtml",
@@ -197,6 +196,11 @@ mod tests {
"missing {entry} in {edition:?}"
);
}
+ let cover_entry = match edition {
+ Edition::Standard => "OEBPS/cover.png",
+ Edition::X4 => "OEBPS/cover.jpg",
+ };
+ assert!(contains_entry(&zip, cover_entry), "missing {cover_entry}");
// No leftover temp file.
assert!(
!dir.path()
diff --git a/src/epub/templates/colophon.xhtml b/src/epub/templates/colophon.xhtml
index a8a488c..441f8d3 100644
--- a/src/epub/templates/colophon.xhtml
+++ b/src/epub/templates/colophon.xhtml
@@ -7,17 +7,15 @@
feed reader: entries are deduplicated, read in full, weighed against social
proof, then scored, sectioned and introduced by a language model.
-
- - Issue
- No. {{ issue_number }} · {{ display_date }}
- - Generated
- {{ generated_at }}
- - Curation model
- {{ model }}
- - Entries considered
- {{ entries_fetched }} from {{ feeds_seen }} feeds
- - Candidates scored
- {{ candidates }}
- - Articles selected
- {{ article_count }} across {{ section_count }} sections
- - Words
- {{ total_words }} · {{ reading_line }}
- - Token cost
- {{ cost_usd }}
- - Generator
- {{ generator_version }}
-
+ Issue: No. {{ issue_number }} · {{ display_date }}
+ Generated: {{ generated_at }}
+ Curation model: {{ model }}
+ Entries considered: {{ entries_fetched }} from {{ feeds_seen }} feeds
+ Candidates scored: {{ candidates }}
+ Articles selected: {{ article_count }} across {{ section_count }} sections
+ Words: {{ total_words }} · {{ reading_line }}
+ Token cost: {{ cost_usd }}
+ Generator: {{ generator_version }}
Article text belongs to its authors and publications; excerpts and links are
provided for personal reading. Comment excerpts belong to their posters.
diff --git a/src/epub/templates/cover_page.xhtml b/src/epub/templates/cover_page.xhtml
index 8489323..9717a0c 100644
--- a/src/epub/templates/cover_page.xhtml
+++ b/src/epub/templates/cover_page.xhtml
@@ -1,5 +1,5 @@
{% extends "base.xhtml" %}
{% block body_class %}cover-page{% endblock %}
{% block content %}
-

+ 
{% endblock %}
diff --git a/src/epub/templates/style-x4.css b/src/epub/templates/style-x4.css
index 38b7556..5c858f3 100644
--- a/src/epub/templates/style-x4.css
+++ b/src/epub/templates/style-x4.css
@@ -178,11 +178,15 @@ p.comment-line {
margin: 0 0 0.35em 0;
}
-dt.fact-key {
- font-weight: bold;
- margin-top: 0.35em;
+.world-summary {
+ margin: 0.2em 0 0.45em 0;
+ font-style: italic;
}
-dd.fact-value {
- margin: 0 0 0 0.8em;
+.world-overview {
+ margin-bottom: 0.7em;
+}
+
+.fact-line {
+ margin: 0 0 0.35em 0;
}
diff --git a/src/epub/templates/style.css b/src/epub/templates/style.css
index 48b0a95..3ff1c77 100644
--- a/src/epub/templates/style.css
+++ b/src/epub/templates/style.css
@@ -275,16 +275,20 @@ blockquote.comment blockquote.comment {
margin-bottom: 0.25em;
}
+.world-summary {
+ margin: 0.2em 0 0.45em 0;
+ font-style: italic;
+}
+
+.world-overview {
+ margin-bottom: 0.7em;
+}
+
.attribution {
font-size: 0.8em;
font-style: italic;
}
-.colophon-facts dt {
- font-variant: small-caps;
- margin-top: 0.4em;
-}
-
-.colophon-facts dd {
- margin: 0 0 0 1em;
+.fact-line {
+ margin: 0 0 0.35em 0;
}
diff --git a/src/lib.rs b/src/lib.rs
index 63f15f7..c74f837 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,8 +8,8 @@
//!
//! ```text
//! Miniflux ingest → dedupe → extraction → persist → social enrichment
-//! → pre-filter → LLM scoring → selection → comments → world briefing
-//! → editorial → EPUB build (standard + X4) → XTC → publish → report
+//! → pre-filter → LLM scoring → selection → comments → editorial
+//! → world briefing → EPUB build (standard + X4) → XTC → publish → report
//! ```
pub mod auth;
diff --git a/src/pipeline.rs b/src/pipeline.rs
index 664a008..06b4d36 100644
--- a/src/pipeline.rs
+++ b/src/pipeline.rs
@@ -2,8 +2,8 @@
//!
//! ```text
//! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
-//! ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ world briefing
-//! ─▶ editorial ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
+//! ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial
+//! ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
//! ```
//!
//! Failure policy (notes §3):
@@ -422,15 +422,7 @@ async fn run_stages(
report.counts.discussions = comments::fetch_all(&http, &mut lineup.picks).await as i64;
report.timings.record("comments", elapsed_ms(stage));
- // --- Stage 9: world briefing (§3.8) — non-fatal by construction ---
- let stage = Timestamp::now();
- let world_briefing = world::fetch_optional(&http, date, config.world_briefing).await;
- if config.world_briefing && world_briefing.is_none() {
- report.warn("the world briefing was unavailable; the section is omitted");
- }
- report.timings.record("world", elapsed_ms(stage));
-
- // --- Stage 10: editorial (§3.6 C) ---
+ // --- Stage 9: editorial (§3.6 C) ---
let stage = Timestamp::now();
let editorial = match curator.editorial(&lineup).await {
Ok(editorial) => editorial,
@@ -444,6 +436,23 @@ async fn run_stages(
apply_summaries(&mut lineup, &editorial);
report.timings.record("editorial", elapsed_ms(stage));
+ // --- Stage 10: completed-day World Briefing (§3.8), best effort ---
+ // Editorial retains budget priority; only the remaining metered budget is
+ // available for per-event summaries and the overview.
+ let stage = Timestamp::now();
+ let mut world_briefing = world::fetch_optional(&http, date, config.world_briefing).await;
+ if config.world_briefing {
+ match world_briefing.as_mut() {
+ Some(briefing) => {
+ for warning in world::enrich(&http, briefing, curator.llm.as_ref()).await {
+ report.warn(warning);
+ }
+ }
+ None => report.warn("the world briefing was unavailable; the section is omitted"),
+ }
+ }
+ report.timings.record("world", elapsed_ms(stage));
+
// --- Stage 11: assemble the issue (§3.10) ---
let issue_number = db
.next_issue_number(date)
diff --git a/src/types.rs b/src/types.rs
index dfdcaac..14f1e77 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -399,8 +399,29 @@ pub struct WorldBriefing {
pub date: Date,
/// Portal URL the content came from (also used for CC BY-SA attribution).
pub source_url: String,
- /// Sanitized ``-style markup of the day's events.
- pub body_html: String,
+ /// Optional synthesized overview of the completed day's events.
+ pub overview: Option,
+ /// Categories in portal order, including every nested list item.
+ pub sections: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct WorldBriefingSection {
+ pub title: String,
+ pub events: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct WorldEvent {
+ /// Stable positional key such as `s1-e2-1`.
+ pub id: String,
+ /// Plain source text from the Wikipedia list item (excluding child lists).
+ pub source_text: String,
+ /// Same-host English Wikipedia article links found in this list item.
+ pub links: Vec,
+ pub children: Vec,
+ /// LLM enrichment, attached only to leaf news statements.
+ pub summary: Option,
}
/// Reserved section name for [`WorldBriefing`] — never offered to the LLM (§3.6).
diff --git a/src/world.rs b/src/world.rs
index 4604d2a..a72121e 100644
--- a/src/world.rs
+++ b/src/world.rs
@@ -1,31 +1,26 @@
-//! World Briefing from the Wikipedia Current Events portal (spec §3.8).
-//!
-//! Failure is non-fatal: the section is simply omitted.
+//! Structured, best-effort World Briefing from Wikipedia Current Events.
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
+use futures::{StreamExt, stream};
use jiff::civil::Date;
-use scraper::node::Node;
+use scraper::{ElementRef, node::Node};
+use serde::Deserialize;
+use url::Url;
-use crate::epub::images::{text_escape, to_xhtml};
-use crate::types::WorldBriefing;
+use crate::curate::llm::{LlmClient, LlmError};
+use crate::epub::images::text_escape;
+use crate::types::{WorldBriefing, WorldBriefingSection, WorldEvent};
-/// `ego_tree::NodeRef<'_, Node>` without depending on `ego_tree` directly.
type NodeRef<'a> = as std::ops::Deref>::Target;
-/// Portal page pattern: `Portal:Current_events/{YYYY}_{Month}_{D}` (§3.8).
pub const PORTAL_BASE: &str = "https://en.wikipedia.org/wiki/Portal:Current_events/";
-/// MediaWiki REST HTML endpoint used to fetch the rendered page (§3.8).
pub const REST_HTML_BASE: &str = "https://en.wikipedia.org/api/rest_v1/page/html/";
-/// Attribution line required by the portal's licence (§3.8).
pub const ATTRIBUTION: &str = "Source: Wikipedia Current Events Portal, CC BY-SA 4.0.";
-/// How many days back [`fetch_with_fallback`] will look for a populated page.
-///
-/// The portal page for a day is created as an empty stub a day ahead and filled
-/// in over the course of that day, so the 05:30 run finds nothing under the
-/// issue's own date. Walking back one or two days lands on a complete page —
-/// which is also the news the reader has not seen yet at breakfast.
pub const MAX_LOOKBACK_DAYS: i8 = 3;
+const SUMMARY_BATCH_SIZE: usize = 6;
+const ARTICLE_CONCURRENCY: usize = 8;
+const MAX_LEAD_CHARS: usize = 2_400;
const MONTHS: [&str; 12] = [
"January",
@@ -41,8 +36,6 @@ const MONTHS: [&str; 12] = [
"November",
"December",
];
-
-/// Containers the day's events live in, most specific first (§3.8).
const CONTENT_SELECTORS: &[&str] = &[
"div.current-events-content",
"div.description",
@@ -50,14 +43,10 @@ const CONTENT_SELECTORS: &[&str] = &[
"section",
"body",
];
-
-/// Elements whose entire subtree is dropped: citations, edit links, chrome.
const DROP_ELEMENTS: &[&str] = &[
"script", "style", "sup", "table", "figure", "img", "link", "meta", "noscript", "input",
- "button", "h1", "h2", "h3", "h4", "h5", "h6",
+ "button",
];
-
-/// Class fragments marking wiki chrome rather than content.
const DROP_CLASSES: &[&str] = &[
"mw-editsection",
"reference",
@@ -78,7 +67,6 @@ pub enum WorldError {
Empty(Date),
}
-/// Build the portal page title for a date, e.g. `2026_August_15` (§3.8).
pub fn portal_title(date: Date) -> String {
let month = MONTHS
.get((date.month() as usize).saturating_sub(1))
@@ -87,12 +75,10 @@ pub fn portal_title(date: Date) -> String {
format!("{}_{}_{}", date.year(), month, date.day())
}
-/// Human-readable portal URL, used for the CC BY-SA attribution link (§3.8).
pub fn portal_url(date: Date) -> String {
format!("{PORTAL_BASE}{}", portal_title(date))
}
-/// MediaWiki REST HTML URL for the day's portal page (§3.8).
pub fn rest_html_url(date: Date) -> String {
format!(
"{REST_HTML_BASE}Portal%3ACurrent_events%2F{}",
@@ -100,8 +86,6 @@ pub fn rest_html_url(date: Date) -> String {
)
}
-/// Fetch the day's portal page, strip citations/edit links, flatten internal
-/// links to plain text and return a compact briefing (§3.8).
pub async fn fetch(http: &reqwest::Client, date: Date) -> Result {
let url = rest_html_url(date);
tracing::debug!(%url, "fetching the world briefing");
@@ -112,56 +96,40 @@ pub async fn fetch(http: &reqwest::Client, date: Date) -> Result Vec {
- (0..=max_days_back.max(0))
- .map_while(|back| date.checked_sub(jiff::Span::new().days(back)).ok())
+/// Candidate pages begin with the previous calendar day, then walk backward.
+pub fn candidate_days(issue_date: Date, max_days_back: i8) -> Vec {
+ (1..=max_days_back.max(1))
+ .map_while(|back| issue_date.checked_sub(jiff::Span::new().days(back)).ok())
.collect()
}
-/// Fetch the newest populated portal page at or before `date`, looking back at
-/// most [`MAX_LOOKBACK_DAYS`] days (§3.8).
-///
-/// The issue's own day is almost always still an empty stub at 05:30, so this is
-/// the entry point the pipeline uses; the returned briefing carries the date it
-/// actually covers in [`WorldBriefing::date`].
pub async fn fetch_with_fallback(
http: &reqwest::Client,
- date: Date,
+ issue_date: Date,
max_days_back: i8,
) -> Result {
- let mut last = WorldError::Empty(date);
- for day in candidate_days(date, max_days_back) {
+ let mut last = WorldError::Empty(issue_date);
+ for day in candidate_days(issue_date, max_days_back) {
match fetch(http, day).await {
- Ok(briefing) => {
- if day != date {
- tracing::info!(
- %date,
- covering = %day,
- "the issue day's portal page was not populated yet; using an earlier day"
- );
- }
- return Ok(briefing);
- }
- Err(e) => {
- tracing::debug!(%day, "world briefing not available for this day: {e}");
- last = e;
+ Ok(briefing) => return Ok(briefing),
+ Err(error) => {
+ tracing::debug!(%day, "world briefing not available for this day: {error}");
+ last = error;
}
}
}
Err(last)
}
-/// Best-effort wrapper used by the pipeline: never fails the run (§3.8).
pub async fn fetch_optional(
http: &reqwest::Client,
date: Date,
@@ -171,123 +139,429 @@ pub async fn fetch_optional(
return None;
}
match fetch_with_fallback(http, date, MAX_LOOKBACK_DAYS).await {
- Ok(b) => Some(b),
- Err(e) => {
- tracing::warn!(%date, "world briefing unavailable: {e}");
+ Ok(briefing) => Some(briefing),
+ Err(error) => {
+ tracing::warn!(%date, "world briefing unavailable: {error}");
None
}
}
}
-/// Extract the day's bulleted events from a rendered portal page (§3.8).
-///
-/// Citations, edit links and navigation are dropped; internal links become plain
-/// text; the result is a sanitized ``/`
` fragment.
-pub fn extract_events(html: &str) -> Option {
- let doc = scraper::Html::parse_document(html);
- for selector in CONTENT_SELECTORS {
- let Ok(sel) = scraper::Selector::parse(selector) else {
- continue;
- };
- for container in doc.select(&sel) {
- let mut out = String::new();
- walk_children(*container, &mut out);
- let cleaned = sanitize(&out);
- if !cleaned.is_empty() && cleaned.contains("- ") {
- return Some(to_xhtml(&cleaned));
- }
- }
- }
- None
+fn dropped(element: &scraper::node::Element) -> bool {
+ DROP_ELEMENTS.contains(&element.name())
+ || element.attr("role") == Some("navigation")
+ || element.attr("class").is_some_and(|class| {
+ DROP_CLASSES
+ .iter()
+ .any(|drop| class.split_whitespace().any(|name| name == *drop))
+ })
}
-fn sanitize(fragment: &str) -> String {
- let tags: HashSet<&str> = ["p", "ul", "ol", "li", "strong", "em", "br"]
- .into_iter()
- .collect();
- ammonia::Builder::new()
- .tags(tags)
- .clean(fragment)
- .to_string()
- .trim()
- .to_string()
-}
-
-fn is_dropped(el: &scraper::node::Element) -> bool {
- if DROP_ELEMENTS.contains(&el.name()) {
- return true;
- }
- if let Some(class) = el.attr("class")
- && DROP_CLASSES
- .iter()
- .any(|dropped| class.split_whitespace().any(|c| c == *dropped))
- {
- return true;
- }
- if el.attr("role") == Some("navigation") {
- return true;
- }
- false
-}
-
-fn walk_children(node: NodeRef<'_>, out: &mut String) {
- for child in node.children() {
- walk(child, out);
- }
-}
-
-fn walk(node: NodeRef<'_>, out: &mut String) {
+fn node_text(node: NodeRef<'_>, skip_lists: bool, out: &mut String) {
match node.value() {
- Node::Text(text) => out.push_str(&text_escape(text)),
- Node::Element(el) => {
- if is_dropped(el) {
+ Node::Text(text) => {
+ out.push_str(&text);
+ out.push(' ');
+ }
+ Node::Element(element) => {
+ if dropped(&element) || (skip_lists && matches!(element.name(), "ul" | "ol")) {
return;
}
- match el.name() {
- "ul" | "ol" | "li" | "p" => {
- let name = el.name();
- out.push('<');
- out.push_str(name);
- out.push('>');
- walk_children(node, out);
- out.push_str("");
- out.push_str(name);
- out.push('>');
- }
- "b" | "strong" => {
- out.push_str("");
- walk_children(node, out);
- out.push_str("");
- }
- "i" | "em" => {
- out.push_str("");
- walk_children(node, out);
- out.push_str("");
- }
- "dt" => {
- out.push_str("
");
- walk_children(node, out);
- out.push_str("
");
- }
- "br" => out.push(' '),
- // `a`, `span`, `div`, `dl`, `dd`, `section` … are transparent:
- // internal links keep their text only (§3.8).
- _ => walk_children(node, out),
+ for child in node.children() {
+ node_text(child, skip_lists, out);
}
}
_ => {}
}
}
-/// Render the briefing to sanitized XHTML with the CC BY-SA attribution (§3.8).
+fn clean_text(text: &str) -> String {
+ text.split_whitespace().collect::>().join(" ")
+}
+
+fn element_text(element: ElementRef<'_>, skip_lists: bool) -> String {
+ let mut out = String::new();
+ node_text(*element, skip_lists, &mut out);
+ clean_text(&out)
+}
+
+fn article_link(raw: &str, base: &Url) -> Option {
+ let mut url = if let Some(title) = raw.strip_prefix("./") {
+ Url::parse(&format!("https://en.wikipedia.org/wiki/{title}")).ok()?
+ } else {
+ base.join(raw).ok()?
+ };
+ if url.scheme() != "https" || url.host_str() != Some("en.wikipedia.org") {
+ return None;
+ }
+ let title = url.path().strip_prefix("/wiki/")?;
+ if title.is_empty() || title.contains(':') {
+ return None;
+ }
+ url.set_query(None);
+ url.set_fragment(None);
+ Some(url.into())
+}
+
+fn links_without_child_lists(element: ElementRef<'_>, base: &Url) -> Vec {
+ fn walk(node: NodeRef<'_>, base: &Url, links: &mut Vec) {
+ let Node::Element(element) = node.value() else {
+ return;
+ };
+ if dropped(&element) || matches!(element.name(), "ul" | "ol") {
+ return;
+ }
+ if element.name() == "a"
+ && let Some(href) = element.attr("href")
+ && let Some(url) = article_link(href, base)
+ && !links.contains(&url)
+ {
+ links.push(url);
+ }
+ for child in node.children() {
+ walk(child, base, links);
+ }
+ }
+ let mut links = Vec::new();
+ for child in element.children() {
+ walk(child, base, &mut links);
+ }
+ links
+}
+
+fn parse_list(list: ElementRef<'_>, base: &Url, prefix: &str) -> Vec {
+ list.children()
+ .filter_map(ElementRef::wrap)
+ .filter(|element| element.value().name() == "li" && !dropped(element.value()))
+ .enumerate()
+ .filter_map(|(index, li)| {
+ let id = format!("{prefix}{}", index + 1);
+ let source_text = element_text(li, true);
+ let mut children = Vec::new();
+ for child in li.children().filter_map(ElementRef::wrap) {
+ if matches!(child.value().name(), "ul" | "ol") {
+ children.extend(parse_list(child, base, &format!("{id}-")));
+ }
+ }
+ (!source_text.is_empty() || !children.is_empty()).then(|| WorldEvent {
+ id,
+ source_text,
+ links: links_without_child_lists(li, base),
+ children,
+ summary: None,
+ })
+ })
+ .collect()
+}
+
+fn category_title(element: ElementRef<'_>) -> Option {
+ if matches!(element.value().name(), "p" | "h2" | "h3" | "h4" | "dt") {
+ let text = element_text(element, false);
+ (!text.is_empty()).then_some(text)
+ } else {
+ None
+ }
+}
+
+fn parse_container(
+ container: ElementRef<'_>,
+ base: &Url,
+ section_offset: usize,
+) -> Vec {
+ let mut current_title: Option = None;
+ let mut sections = Vec::new();
+ for child in container.children().filter_map(ElementRef::wrap) {
+ if dropped(child.value()) {
+ continue;
+ }
+ if let Some(title) = category_title(child) {
+ current_title = Some(title);
+ continue;
+ }
+ if matches!(child.value().name(), "ul" | "ol") {
+ let section_number = section_offset + sections.len() + 1;
+ let events = parse_list(child, base, &format!("s{section_number}-e"));
+ if !events.is_empty() {
+ sections.push(WorldBriefingSection {
+ title: current_title
+ .take()
+ .unwrap_or_else(|| "Other events".into()),
+ events,
+ });
+ }
+ }
+ }
+ sections
+}
+
+/// Parse all containers for the first selector that yields events.
+pub fn extract_events(html: &str, source_url: &str) -> Option> {
+ let document = scraper::Html::parse_document(html);
+ let base = Url::parse(source_url).ok()?;
+ for selector in CONTENT_SELECTORS {
+ let selector = scraper::Selector::parse(selector).ok()?;
+ let mut sections = Vec::new();
+ for container in document.select(&selector) {
+ let parsed = parse_container(container, &base, sections.len());
+ sections.extend(parsed);
+ }
+ if !sections.is_empty() {
+ return Some(sections);
+ }
+ }
+ None
+}
+
+#[derive(Clone)]
+struct LeafInput {
+ id: String,
+ source_text: String,
+ context: Vec,
+ links: Vec,
+}
+
+fn collect_leaves(
+ events: &[WorldEvent],
+ ancestors: &[String],
+ inherited_links: &[String],
+ out: &mut Vec,
+) {
+ for event in events {
+ let mut context = ancestors.to_vec();
+ let mut links = inherited_links.to_vec();
+ for link in &event.links {
+ if !links.contains(link) {
+ links.push(link.clone());
+ }
+ }
+ if event.children.is_empty() {
+ out.push(LeafInput {
+ id: event.id.clone(),
+ source_text: event.source_text.clone(),
+ context,
+ links,
+ });
+ } else {
+ if !event.source_text.is_empty() {
+ context.push(event.source_text.clone());
+ }
+ collect_leaves(&event.children, &context, &links, out);
+ }
+ }
+}
+
+fn article_rest_url(article_url: &str) -> Option {
+ let article = Url::parse(article_url).ok()?;
+ let title = article.path().strip_prefix("/wiki/")?;
+ Url::parse(&format!("{REST_HTML_BASE}{title}")).ok()
+}
+
+fn lead_text(html: &str) -> Option {
+ let document = scraper::Html::parse_document(html);
+ let selector = scraper::Selector::parse("p").ok()?;
+ let mut parts = Vec::new();
+ for paragraph in document.select(&selector) {
+ let text = element_text(paragraph, false);
+ if text.len() >= 40 {
+ parts.push(text);
+ }
+ if parts.len() == 3 {
+ break;
+ }
+ }
+ let text = parts.join(" ");
+ if text.is_empty() {
+ None
+ } else {
+ Some(text.chars().take(MAX_LEAD_CHARS).collect())
+ }
+}
+
+async fn fetch_leads(http: &reqwest::Client, links: &[String]) -> (HashMap, usize) {
+ let unique: HashSet = links.iter().cloned().collect();
+ let results = stream::iter(unique.into_iter().map(|link| {
+ let http = http.clone();
+ async move {
+ let result = match article_rest_url(&link) {
+ Some(url) => match http.get(url).send().await {
+ Ok(response) => match response.error_for_status() {
+ Ok(response) => {
+ response.text().await.ok().and_then(|html| lead_text(&html))
+ }
+ Err(_) => None,
+ },
+ Err(_) => None,
+ },
+ None => None,
+ };
+ (link, result)
+ }
+ }))
+ .buffer_unordered(ARTICLE_CONCURRENCY)
+ .collect::>()
+ .await;
+ let failures = results.iter().filter(|(_, lead)| lead.is_none()).count();
+ let leads = results
+ .into_iter()
+ .filter_map(|(link, lead)| lead.map(|lead| (link, lead)))
+ .collect();
+ (leads, failures)
+}
+
+#[derive(Deserialize)]
+struct SummaryResponse {
+ summaries: HashMap,
+}
+
+fn apply_summaries(events: &mut [WorldEvent], summaries: &HashMap) {
+ for event in events {
+ if event.children.is_empty() {
+ event.summary = summaries
+ .get(&event.id)
+ .map(|summary| clean_text(summary))
+ .filter(|summary| !summary.is_empty());
+ } else {
+ apply_summaries(&mut event.children, summaries);
+ }
+ }
+}
+
+/// Enrich leaves after article editorial work has consumed its budget priority.
+/// Every failure is reported as a warning while the source hierarchy survives.
+pub async fn enrich(
+ http: &reqwest::Client,
+ briefing: &mut WorldBriefing,
+ llm: Option<&LlmClient>,
+) -> Vec {
+ let Some(llm) = llm else {
+ return vec!["World Briefing enrichment skipped because the LLM is unavailable".into()];
+ };
+ let mut leaves = Vec::new();
+ for section in &briefing.sections {
+ collect_leaves(
+ §ion.events,
+ std::slice::from_ref(§ion.title),
+ &[],
+ &mut leaves,
+ );
+ }
+ let all_links = leaves
+ .iter()
+ .flat_map(|leaf| leaf.links.clone())
+ .collect::>();
+ let (leads, failed_links) = fetch_leads(http, &all_links).await;
+ let mut warnings = Vec::new();
+ if failed_links > 0 {
+ warnings.push(format!(
+ "World Briefing could not fetch {failed_links} linked Wikipedia article lead(s); source bullets were retained"
+ ));
+ }
+
+ let mut summaries = HashMap::new();
+ for batch in leaves.chunks(SUMMARY_BATCH_SIZE) {
+ let items = batch
+ .iter()
+ .map(|leaf| serde_json::json!({
+ "id": leaf.id,
+ "ancestor_labels": leaf.context,
+ "source_bullet": leaf.source_text,
+ "wikipedia_leads": leaf.links.iter().filter_map(|link| leads.get(link)).collect::>(),
+ }))
+ .collect::>();
+ let prompt = format!(
+ "Summarize each World Briefing event in two sentences and approximately 35-60 words. Use only the source bullet and Wikipedia leads supplied. Return JSON exactly as {{\"summaries\":{{\"event-id\":\"summary\"}}}}; keys must be the supplied stable IDs. Events:\n{}",
+ serde_json::to_string(&items).unwrap_or_default()
+ );
+ match llm.complete_json::(&prompt, 0.2).await {
+ Ok(response) => {
+ for leaf in batch {
+ if let Some(summary) = response.summaries.get(&leaf.id) {
+ summaries.insert(leaf.id.clone(), summary.clone());
+ }
+ }
+ }
+ Err(error) => {
+ warnings.push(format!("World Briefing summary batch failed: {error}"));
+ if matches!(error, LlmError::BudgetExceeded { .. }) {
+ break;
+ }
+ }
+ }
+ }
+ for section in &mut briefing.sections {
+ apply_summaries(&mut section.events, &summaries);
+ }
+
+ if !summaries.is_empty() {
+ let ordered = leaves
+ .iter()
+ .filter_map(|leaf| {
+ summaries
+ .get(&leaf.id)
+ .map(|summary| format!("{}: {summary}", leaf.id))
+ })
+ .collect::>()
+ .join("\n");
+ let prompt = format!(
+ "Write a neutral 150-220 word overview of this completed news day in 2-3 paragraphs. Ground it only in these event summaries and return plain text with blank lines between paragraphs:\n{ordered}"
+ );
+ match llm.complete_text(&prompt, 0.2).await {
+ Ok(overview) if !overview.trim().is_empty() => {
+ briefing.overview = Some(overview.trim().to_string())
+ }
+ Ok(_) => warnings
+ .push("World Briefing overview was empty; source bullets were retained".into()),
+ Err(error) => warnings.push(format!("World Briefing overview failed: {error}")),
+ }
+ }
+ warnings
+}
+
+fn render_events(events: &[WorldEvent], out: &mut String) {
+ out.push_str("");
+}
+
pub fn render_xhtml(briefing: &WorldBriefing) -> String {
- format!(
- "{}\n {} {}
\n",
- briefing.body_html,
- text_escape(ATTRIBUTION),
- text_escape(&briefing.source_url),
- text_escape(&briefing.source_url)
- )
+ let mut out = String::new();
+ if let Some(overview) = &briefing.overview {
+ for paragraph in overview
+ .split("\n\n")
+ .map(str::trim)
+ .filter(|p| !p.is_empty())
+ {
+ out.push_str("");
+ out.push_str(&text_escape(paragraph));
+ out.push_str("
");
+ }
+ }
+ for section in &briefing.sections {
+ out.push_str("");
+ out.push_str(&text_escape(§ion.title));
+ out.push_str("
");
+ render_events(§ion.events, &mut out);
+ }
+ out.push_str("");
+ out.push_str(&text_escape(ATTRIBUTION));
+ out.push_str(" ");
+ out.push_str(&text_escape(&briefing.source_url));
+ out.push_str("
");
+ out
}
#[cfg(test)]
@@ -295,101 +569,271 @@ mod tests {
use super::*;
fn fixture() -> String {
- let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("tests/fixtures/wikipedia_current_events.html");
- std::fs::read_to_string(path).expect("fixture must exist")
+ std::fs::read_to_string(
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("tests/fixtures/wikipedia_current_events.html"),
+ )
+ .expect("fixture")
}
#[test]
- fn portal_titles_and_urls_match_the_spec() {
- let date: Date = "2026-08-15".parse().unwrap();
- assert_eq!(portal_title(date), "2026_August_15");
- assert_eq!(
- portal_url(date),
- "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_15"
+ fn parses_categories_hierarchy_and_article_links() {
+ let sections =
+ extract_events(&fixture(), &portal_url("2026-08-15".parse().unwrap())).unwrap();
+ assert_eq!(sections.len(), 3);
+ assert_eq!(sections[0].title, "Armed conflicts and attacks");
+ assert_eq!(sections[0].events.len(), 2);
+ assert_eq!(sections[0].events[0].children.len(), 1);
+ assert_eq!(sections[0].events[0].id, "s1-e1");
+ assert_eq!(sections[0].events[0].children[0].id, "s1-e1-1");
+ assert!(
+ sections[0].events[0]
+ .links
+ .iter()
+ .any(|url| url.ends_with("/Border_dispute"))
);
- assert_eq!(
- rest_html_url(date),
- "https://en.wikipedia.org/api/rest_v1/page/html/Portal%3ACurrent_events%2F2026_August_15"
+ assert!(
+ sections[0].events[0]
+ .links
+ .iter()
+ .any(|url| url.ends_with("/United_Nations"))
);
- let single_digit: Date = "2026-01-05".parse().unwrap();
- assert_eq!(portal_title(single_digit), "2026_January_5");
+ assert!(
+ sections[1].events[0]
+ .links
+ .iter()
+ .any(|url| url.ends_with("/Boston"))
+ );
+ assert!(sections.iter().all(|section| {
+ section
+ .events
+ .iter()
+ .all(|event| !event.source_text.contains("[1]"))
+ }));
}
#[test]
- fn extracts_events_and_strips_wiki_chrome() {
- let body = extract_events(&fixture()).expect("events");
- assert!(body.contains(""));
- assert!(body.contains("Armed conflicts and attacks"));
- assert!(body.contains("Heavy rain floods the Charles River basin"));
- // Internal links are flattened to plain text.
- assert!(!body.contains("One
+ "#;
+ let sections = extract_events(
+ html,
+ "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_14",
+ )
+ .unwrap();
+ assert_eq!(
+ sections
+ .iter()
+ .map(|s| s.title.as_str())
+ .collect::>(),
+ ["One", "Two"]
+ );
+ assert_eq!(sections[1].events[0].id, "s2-e1");
+ }
+
+ #[test]
+ fn filters_external_special_fragment_and_edit_links() {
+ let html = r#""#;
+ let sections = extract_events(
+ html,
+ "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_14",
+ )
+ .unwrap();
+ assert_eq!(
+ sections[0].events[0].links,
+ ["https://en.wikipedia.org/wiki/Valid_article"]
+ );
+ }
+
+ #[test]
+ fn candidates_start_yesterday_across_boundaries() {
+ assert_eq!(
+ candidate_days("2026-01-01".parse().unwrap(), 3),
+ [
+ "2025-12-31".parse().unwrap(),
+ "2025-12-30".parse().unwrap(),
+ "2025-12-29".parse().unwrap(),
+ ]
+ );
}
#[test]
fn missing_events_yield_none() {
- assert!(extract_events("Nothing here
").is_none());
- assert!(extract_events("").is_none());
- }
-
- /// Wikipedia creates each day's portal page as an empty stub a day ahead and
- /// fills it in over that day, so the 05:30 run sees this, not news (§3.8).
- /// Its only `- `s are the edit/history/watch navbar, which must not count
- /// as content — otherwise the fallback never triggers.
- #[test]
- fn an_unpopulated_stub_page_yields_no_events() {
- let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("tests/fixtures/wikipedia_current_events_empty_stub.html");
- let stub = std::fs::read_to_string(path).expect("fixture must exist");
- assert!(stub.contains("current-events-navbar"), "fixture sanity");
- assert!(extract_events(&stub).is_none());
- }
-
- #[test]
- fn the_fallback_walks_backwards_from_the_issue_date() {
- let date: Date = "2026-08-15".parse().unwrap();
- assert_eq!(
- candidate_days(date, 3),
- [
- "2026-08-15".parse().unwrap(),
- "2026-08-14".parse().unwrap(),
- "2026-08-13".parse().unwrap(),
- "2026-08-12".parse().unwrap(),
- ]
- );
- // Never forwards, and never fewer than the issue's own day.
- assert_eq!(candidate_days(date, 0), [date]);
- assert_eq!(candidate_days(date, -1), [date]);
- // Month and year boundaries.
- assert_eq!(
- candidate_days("2026-01-01".parse().unwrap(), 2),
- [
- "2026-01-01".parse().unwrap(),
- "2025-12-31".parse().unwrap(),
- "2025-12-30".parse().unwrap(),
- ]
+ assert!(
+ extract_events(
+ "
Nothing
",
+ "https://en.wikipedia.org/wiki/X"
+ )
+ .is_none()
);
}
#[test]
- fn rendering_appends_the_attribution() {
- let briefing = WorldBriefing {
- date: "2026-08-15".parse().unwrap(),
- source_url: portal_url("2026-08-15".parse().unwrap()),
- body_html: "".into(),
+ fn rendering_preserves_hierarchy_summaries_and_attribution() {
+ let mut briefing = WorldBriefing {
+ date: "2026-08-14".parse().unwrap(),
+ source_url: portal_url("2026-08-14".parse().unwrap()),
+ overview: Some("First paragraph.\n\nSecond paragraph.".into()),
+ sections: extract_events(&fixture(), &portal_url("2026-08-14".parse().unwrap()))
+ .unwrap(),
};
+ briefing.sections[0].events[0].children[0].summary = Some("Grounded detail.".into());
let xhtml = render_xhtml(&briefing);
+ assert!(xhtml.contains("world-overview"));
+ assert!(xhtml.contains("world-summary"));
+ assert!(xhtml.contains("Grounded detail."));
assert!(xhtml.contains("CC BY-SA 4.0"));
- assert!(xhtml.contains(
- ""
- ));
- assert!(xhtml.contains("Something happened"));
+ }
+
+ fn two_leaf_briefing() -> WorldBriefing {
+ WorldBriefing {
+ date: "2026-08-14".parse().unwrap(),
+ source_url: portal_url("2026-08-14".parse().unwrap()),
+ overview: None,
+ sections: vec![WorldBriefingSection {
+ title: "News".into(),
+ events: vec![
+ WorldEvent {
+ id: "s1-e1".into(),
+ source_text: "First raw bullet.".into(),
+ links: vec![],
+ children: vec![],
+ summary: None,
+ },
+ WorldEvent {
+ id: "s1-e2".into(),
+ source_text: "Second raw bullet.".into(),
+ links: vec![],
+ children: vec![],
+ summary: None,
+ },
+ ],
+ }],
+ }
+ }
+
+ fn mock_client(
+ backend: std::sync::Arc,
+ limit: f64,
+ ) -> LlmClient {
+ let config = crate::config::DeepseekConfig::default();
+ LlmClient::with_backend(
+ "mock",
+ "World Briefing test".into(),
+ crate::curate::llm::UsageMeter::new(&config, limit),
+ backend,
+ )
+ }
+
+ #[tokio::test]
+ async fn enrichment_is_keyed_by_id_and_overview_uses_every_summary() {
+ let backend = std::sync::Arc::new(crate::curate::llm::MockBackend::new());
+ backend.push(
+ r#"{"summaries":{"s1-e2":"Summary for the second event.","s1-e1":"Summary for the first event."}}"#,
+ crate::types::TokenUsage::default(),
+ );
+ backend.push(
+ "A complete overview paragraph.\n\nA second overview paragraph.",
+ crate::types::TokenUsage::default(),
+ );
+ let llm = mock_client(backend.clone(), 1.0);
+ let mut briefing = two_leaf_briefing();
+ let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT).unwrap();
+ let warnings = enrich(&http, &mut briefing, Some(&llm)).await;
+ assert!(warnings.is_empty(), "{warnings:?}");
+ assert_eq!(
+ briefing.sections[0].events[0].summary.as_deref(),
+ Some("Summary for the first event.")
+ );
+ assert_eq!(
+ briefing.sections[0].events[1].summary.as_deref(),
+ Some("Summary for the second event.")
+ );
+ assert!(
+ briefing
+ .overview
+ .as_deref()
+ .unwrap()
+ .contains("complete overview")
+ );
+ let prompts = backend.prompts();
+ assert!(prompts[1].user.contains("Summary for the first event."));
+ assert!(prompts[1].user.contains("Summary for the second event."));
+ }
+
+ #[tokio::test]
+ async fn malformed_or_unavailable_llm_preserves_every_raw_bullet() {
+ let backend = std::sync::Arc::new(crate::curate::llm::MockBackend::new());
+ backend.push("not json", crate::types::TokenUsage::default());
+ let llm = mock_client(backend, 1.0);
+ let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT).unwrap();
+ let mut malformed = two_leaf_briefing();
+ let warnings = enrich(&http, &mut malformed, Some(&llm)).await;
+ assert!(!warnings.is_empty());
+ assert_eq!(
+ malformed.sections[0]
+ .events
+ .iter()
+ .map(|event| event.source_text.as_str())
+ .collect::>(),
+ ["First raw bullet.", "Second raw bullet."]
+ );
+ assert!(
+ malformed.sections[0]
+ .events
+ .iter()
+ .all(|event| event.summary.is_none())
+ );
+
+ let mut unavailable = two_leaf_briefing();
+ let warnings = enrich(&http, &mut unavailable, None).await;
+ assert_eq!(unavailable, two_leaf_briefing());
+ assert_eq!(warnings.len(), 1);
+ }
+
+ #[tokio::test]
+ async fn exhausted_budget_makes_no_backend_call_and_keeps_bullets() {
+ let backend = std::sync::Arc::new(crate::curate::llm::MockBackend::new());
+ let llm = mock_client(backend.clone(), 0.01);
+ llm.meter.preload_cost(0.01);
+ let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT).unwrap();
+ let mut briefing = two_leaf_briefing();
+ let warnings = enrich(&http, &mut briefing, Some(&llm)).await;
+ assert_eq!(backend.calls(), 0);
+ assert!(
+ warnings
+ .iter()
+ .any(|warning| warning.contains("cost ceiling"))
+ );
+ assert_eq!(briefing, two_leaf_briefing());
+ }
+
+ #[test]
+ fn leaf_context_inherits_and_deduplicates_parent_article_links() {
+ let events = vec![WorldEvent {
+ id: "s1-e1".into(),
+ source_text: "Parent topic".into(),
+ links: vec!["https://en.wikipedia.org/wiki/Parent".into()],
+ summary: None,
+ children: vec![WorldEvent {
+ id: "s1-e1-1".into(),
+ source_text: "Leaf event".into(),
+ links: vec![
+ "https://en.wikipedia.org/wiki/Parent".into(),
+ "https://en.wikipedia.org/wiki/Leaf".into(),
+ ],
+ children: vec![],
+ summary: None,
+ }],
+ }];
+ let mut leaves = Vec::new();
+ collect_leaves(&events, &["News".into()], &[], &mut leaves);
+ assert_eq!(leaves.len(), 1);
+ assert_eq!(leaves[0].context, ["News", "Parent topic"]);
+ assert_eq!(leaves[0].links.len(), 2);
+ assert!(leaves[0].links.iter().any(|link| link.ends_with("/Parent")));
+ assert!(leaves[0].links.iter().any(|link| link.ends_with("/Leaf")));
}
}
diff --git a/tests/m4_epub.rs b/tests/m4_epub.rs
index 3d19911..fbcde63 100644
--- a/tests/m4_epub.rs
+++ b/tests/m4_epub.rs
@@ -16,6 +16,15 @@ fn contains_entry(zip: &[u8], name: &str) -> bool {
}
/// Read one entry out of the archive, inflating it.
+fn read_entry_bytes(zip: &[u8], name: &str) -> Vec {
+ use std::io::Read as _;
+ let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).expect("zip opens");
+ let mut file = archive.by_name(name).expect("entry exists");
+ let mut out = Vec::new();
+ file.read_to_end(&mut out).expect("entry reads");
+ out
+}
+
fn read_entry(zip: &[u8], name: &str) -> String {
use std::io::Read as _;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).expect("zip opens");
@@ -96,7 +105,24 @@ fn x4_edition_is_built_alongside_the_standard_one() {
);
assert_eq!(&zip[30..38], b"mimetype");
assert!(contains_entry(&zip, "OEBPS/art-1001.xhtml"));
- assert!(contains_entry(&zip, "OEBPS/cover.png"));
+ assert!(contains_entry(&zip, "OEBPS/cover.jpg"));
+ assert!(!contains_entry(&zip, "OEBPS/cover.png"));
+ let jpeg = read_entry_bytes(&zip, "OEBPS/cover.jpg");
+ let decoded = image::load_from_memory(&jpeg).expect("X4 cover decodes");
+ assert_eq!((decoded.width(), decoded.height()), (480, 800));
+ assert_eq!(decoded.color(), image::ColorType::Rgb8);
+ assert!(jpeg.windows(2).any(|marker| marker == [0xff, 0xc0]));
+ assert!(!jpeg.windows(2).any(|marker| marker == [0xff, 0xc2]));
+ let opf = read_entry(&zip, "OEBPS/content.opf");
+ let cover_page = read_entry(&zip, "OEBPS/cover.xhtml");
+ assert!(opf.contains("href=\"cover.jpg\""), "{opf}");
+ assert!(opf.contains("media-type=\"image/jpeg\""), "{opf}");
+ assert!(opf.contains("properties=\"cover-image\""), "{opf}");
+ assert!(
+ opf.contains(""),
+ "{opf}"
+ );
+ assert!(cover_page.contains("src=\"cover.jpg\""), "{cover_page}");
}
/// Both editions land in the same BookOrbit library, which lists books by
@@ -126,6 +152,8 @@ fn the_two_editions_have_distinct_titles_in_the_opf() {
for opf in [&standard_opf, &x4_opf] {
assert!(opf.contains("belongs-to-collection"), "{opf}");
assert!(opf.contains("2026-08-15"), "{opf}");
+ assert!(opf.contains("en"), "{opf}");
+ assert_eq!(opf.matches("id=\"epub-creator-0\"").count(), 1, "{opf}");
}
}
@@ -272,7 +300,27 @@ fn comment_and_world_fixtures_feed_real_chapters() {
"/tests/fixtures/wikipedia_current_events.html"
))
.expect("fixture");
- let body = world::extract_events(&html).expect("events");
- assert!(body.contains("- "));
- assert!(!body.contains("").count(), 9);
+ assert!(!colophon.contains("
Issue:"));
+ assert!(colophon.contains("Generator:"));
+ }
}