Fix world briefing & add LLM enrichment, colophon fix, x4 cover fix
- World Briefing now starts with yesterday, preserves complete nested event hierarchies and Wikipedia links, fetches deduplicated article leads concurrently, and adds stable-ID summaries plus a daily overview. - Enrichment runs after normal editorial work and degrades safely with report warnings. - Colophon facts now use X4-safe block paragraphs. - Standard cover remains 1200×1600 RGB PNG. - X4 cover is a 480×800 baseline RGB JPEG with consistent XHTML and OPF declarations. - Added CrossInk cache-clearing guidance to README.md. - Worked around an epub-builder duplicate XML-ID defect discovered by validation.
This commit is contained in:
@@ -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
|
Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
|
||||||
─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ world briefing
|
─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial
|
||||||
─▶ editorial ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
|
─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
|
||||||
```
|
```
|
||||||
|
|
||||||
Every stage writes to SQLite, so a run is idempotent per date: re-running
|
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 <font> && echo ok` settle it. |
|
| `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 <font> && 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 "<entry>"` — zero means nothing has been published yet. |
|
| 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 "<entry>"` — 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. |
|
| 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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+80
-35
@@ -27,8 +27,19 @@ pub const CREATOR: &str = "The Daily EPUB";
|
|||||||
pub const LANGUAGE: &str = "en";
|
pub const LANGUAGE: &str = "en";
|
||||||
/// Characters of the hex HMAC kept in rating links (§3.9).
|
/// Characters of the hex HMAC kept in rating links (§3.9).
|
||||||
pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN;
|
pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN;
|
||||||
/// Cover image path inside the EPUB.
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub const COVER_HREF: &str = "cover.png";
|
pub struct CoverAsset {
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
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).
|
/// One rendered chapter ready to be added to the EPUB (§3.10).
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -103,6 +114,7 @@ struct CoverSvg {
|
|||||||
struct CoverPage {
|
struct CoverPage {
|
||||||
title: String,
|
title: String,
|
||||||
alt: String,
|
alt: String,
|
||||||
|
cover_href: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@@ -298,21 +310,27 @@ fn cover_svg(
|
|||||||
Ok(tpl.render()?)
|
Ok(tpl.render()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the cover SVG and rasterize it with `resvg` + `tiny-skia`:
|
/// Render the standard cover as RGB PNG and the X4 cover as baseline RGB JPEG.
|
||||||
/// 1200×1600 for `Standard`, 480×800 grayscale for `X4` (§3.10).
|
pub fn render_cover(issue: &Issue, edition: Edition) -> Result<CoverAsset, EpubError> {
|
||||||
pub fn render_cover(issue: &Issue, edition: Edition) -> Result<Vec<u8>, EpubError> {
|
|
||||||
let (width, height) = cover_size(edition);
|
let (width, height) = cover_size(edition);
|
||||||
let grayscale = edition == Edition::X4;
|
|
||||||
let svg = cover_svg(issue, edition, width, height)?;
|
let svg = cover_svg(issue, edition, width, height)?;
|
||||||
match rasterize(&svg, width, height) {
|
let pixmap = match rasterize(&svg, width, height) {
|
||||||
Some(pixmap) => encode_cover(pixmap, grayscale),
|
Some(pixmap) => pixmap,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!("no usable system fonts: falling back to a geometric cover");
|
tracing::warn!("no usable system fonts: falling back to a geometric cover");
|
||||||
let pixmap = draw_fallback_cover(width, height, edition)
|
draw_fallback_cover(width, height, edition)
|
||||||
.ok_or_else(|| EpubError::Build("could not allocate the cover".into()))?;
|
.ok_or_else(|| EpubError::Build("could not allocate the cover".into()))?
|
||||||
encode_cover(pixmap, grayscale)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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<tiny_skia::Pixmap> {
|
fn rasterize(svg: &str, width: u32, height: u32) -> Option<tiny_skia::Pixmap> {
|
||||||
@@ -410,21 +428,22 @@ fn draw_fallback_cover(width: u32, height: u32, edition: Edition) -> Option<tiny
|
|||||||
Some(pixmap)
|
Some(pixmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_cover(pixmap: tiny_skia::Pixmap, grayscale: bool) -> Result<Vec<u8>, EpubError> {
|
fn encode_cover(pixmap: tiny_skia::Pixmap, edition: Edition) -> Result<Vec<u8>, EpubError> {
|
||||||
let (w, h) = (pixmap.width(), pixmap.height());
|
let (w, h) = (pixmap.width(), pixmap.height());
|
||||||
let rgba = image::RgbaImage::from_raw(w, h, pixmap.take_demultiplied())
|
let rgba = image::RgbaImage::from_raw(w, h, pixmap.take_demultiplied())
|
||||||
.ok_or_else(|| EpubError::Build("cover pixel buffer had the wrong size".into()))?;
|
.ok_or_else(|| EpubError::Build("cover pixel buffer had the wrong size".into()))?;
|
||||||
let dynamic = image::DynamicImage::ImageRgba8(rgba);
|
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
|
||||||
let dynamic = if grayscale {
|
let mut bytes = Vec::new();
|
||||||
image::DynamicImage::ImageLuma8(dynamic.to_luma8())
|
match edition {
|
||||||
} else {
|
Edition::Standard => image::DynamicImage::ImageRgb8(rgb).write_to(
|
||||||
image::DynamicImage::ImageRgb8(dynamic.to_rgb8())
|
&mut std::io::Cursor::new(&mut bytes),
|
||||||
};
|
image::ImageFormat::Png,
|
||||||
let mut out = std::io::Cursor::new(Vec::new());
|
),
|
||||||
dynamic
|
Edition::X4 => image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 92)
|
||||||
.write_to(&mut out, image::ImageFormat::Png)
|
.encode_image(&image::DynamicImage::ImageRgb8(rgb)),
|
||||||
|
}
|
||||||
.map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?;
|
.map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?;
|
||||||
Ok(out.into_inner())
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -759,6 +778,7 @@ fn render_cover_page(issue: &Issue, edition: Edition) -> Result<Chapter, EpubErr
|
|||||||
"The Daily EPUB, {} \u{2014} No. {}",
|
"The Daily EPUB, {} \u{2014} No. {}",
|
||||||
issue.meta.display_date, issue.meta.issue_number
|
issue.meta.display_date, issue.meta.issue_number
|
||||||
),
|
),
|
||||||
|
cover_href: cover_href(edition),
|
||||||
};
|
};
|
||||||
Ok(Chapter {
|
Ok(Chapter {
|
||||||
id: "cover".into(),
|
id: "cover".into(),
|
||||||
@@ -854,6 +874,7 @@ fn date_metadata(date: Date) -> MetadataOpfV3 {
|
|||||||
"dcterms:date",
|
"dcterms:date",
|
||||||
&format!(
|
&format!(
|
||||||
"{date}</meta>\n <dc:date>{date}</dc:date>\n \
|
"{date}</meta>\n <dc:date>{date}</dc:date>\n \
|
||||||
|
<dc:language>{LANGUAGE}</dc:language>\n \
|
||||||
<meta property=\"dcterms:issued\">{date}"
|
<meta property=\"dcterms:issued\">{date}"
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
@@ -889,7 +910,7 @@ pub fn assemble(
|
|||||||
edition: Edition,
|
edition: Edition,
|
||||||
chapters: &[Chapter],
|
chapters: &[Chapter],
|
||||||
images_: &[ImageAsset],
|
images_: &[ImageAsset],
|
||||||
cover_png: &[u8],
|
cover: &CoverAsset,
|
||||||
) -> Result<Vec<u8>, EpubError> {
|
) -> Result<Vec<u8>, EpubError> {
|
||||||
let zip = ZipLibrary::new().map_err(|e| epub_err("zip library", e))?;
|
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))?;
|
let mut builder = EpubBuilder::new(zip).map_err(|e| epub_err("epub builder", e))?;
|
||||||
@@ -900,9 +921,6 @@ pub fn assemble(
|
|||||||
builder
|
builder
|
||||||
.metadata("author", CREATOR)
|
.metadata("author", CREATOR)
|
||||||
.map_err(|e| epub_err("author metadata", e))?;
|
.map_err(|e| epub_err("author metadata", e))?;
|
||||||
builder
|
|
||||||
.metadata("lang", LANGUAGE)
|
|
||||||
.map_err(|e| epub_err("lang metadata", e))?;
|
|
||||||
builder
|
builder
|
||||||
.metadata(
|
.metadata(
|
||||||
"generator",
|
"generator",
|
||||||
@@ -948,7 +966,7 @@ pub fn assemble(
|
|||||||
.stylesheet(stylesheet(edition).as_bytes())
|
.stylesheet(stylesheet(edition).as_bytes())
|
||||||
.map_err(|e| epub_err("stylesheet", e))?;
|
.map_err(|e| epub_err("stylesheet", e))?;
|
||||||
builder
|
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))?;
|
.map_err(|e| epub_err("cover image", e))?;
|
||||||
for asset in images_ {
|
for asset in images_ {
|
||||||
builder
|
builder
|
||||||
@@ -1104,7 +1122,17 @@ pub mod fixtures {
|
|||||||
date: "2026-08-15".parse().expect("fixed date"),
|
date: "2026-08-15".parse().expect("fixed date"),
|
||||||
source_url: "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_15"
|
source_url: "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_15"
|
||||||
.into(),
|
.into(),
|
||||||
body_html: "<ul><li>Something happened somewhere.</li></ul>".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 {
|
colophon: Colophon {
|
||||||
model: "deepseek-v4-flash".into(),
|
model: "deepseek-v4-flash".into(),
|
||||||
@@ -1363,22 +1391,39 @@ mod tests {
|
|||||||
fn covers_rasterize_for_both_editions() {
|
fn covers_rasterize_for_both_editions() {
|
||||||
let issue = issue();
|
let issue = issue();
|
||||||
for edition in [Edition::Standard, Edition::X4] {
|
for edition in [Edition::Standard, Edition::X4] {
|
||||||
let png = render_cover(&issue, edition).expect("cover");
|
let cover = render_cover(&issue, edition).expect("cover");
|
||||||
let decoded = image::load_from_memory(&png).expect("cover is a valid png");
|
let decoded = image::load_from_memory(&cover.bytes).expect("cover is a valid image");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(decoded.width(), decoded.height()),
|
(decoded.width(), decoded.height()),
|
||||||
cover_size(edition),
|
cover_size(edition),
|
||||||
"cover size for {edition:?}"
|
"cover size for {edition:?}"
|
||||||
);
|
);
|
||||||
|
assert_eq!(decoded.color(), image::ColorType::Rgb8);
|
||||||
if edition == Edition::X4 {
|
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]
|
#[test]
|
||||||
fn fallback_cover_is_drawn_without_fonts() {
|
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();
|
let decoded = image::load_from_memory(&png).unwrap();
|
||||||
assert_eq!((decoded.width(), decoded.height()), (480, 800));
|
assert_eq!((decoded.width(), decoded.height()), (480, 800));
|
||||||
// Some ink actually landed on the page.
|
// Some ink actually landed on the page.
|
||||||
@@ -1426,8 +1471,8 @@ mod tests {
|
|||||||
assert_eq!(standard.matches("fill=\"#ffffff\"").count(), 1);
|
assert_eq!(standard.matches("fill=\"#ffffff\"").count(), 1);
|
||||||
|
|
||||||
// The badge sits between the stats line and the footer, inside the frame.
|
// The badge sits between the stats line and the footer, inside the frame.
|
||||||
let png = render_cover(&issue, Edition::X4).unwrap();
|
let cover = render_cover(&issue, Edition::X4).unwrap();
|
||||||
let gray = image::load_from_memory(&png).unwrap().to_luma8();
|
let gray = image::load_from_memory(&cover.bytes).unwrap().to_luma8();
|
||||||
let dark_in_badge = (584..634)
|
let dark_in_badge = (584..634)
|
||||||
.flat_map(|y| (144..336).map(move |x| (x, y)))
|
.flat_map(|y| (144..336).map(move |x| (x, y)))
|
||||||
.filter(|&(x, y)| gray.get_pixel(x, y)[0] < 32)
|
.filter(|&(x, y)| gray.get_pixel(x, y)[0] < 32)
|
||||||
|
|||||||
+5
-1
@@ -182,7 +182,6 @@ mod tests {
|
|||||||
"OEBPS/toc.ncx",
|
"OEBPS/toc.ncx",
|
||||||
"OEBPS/nav.xhtml",
|
"OEBPS/nav.xhtml",
|
||||||
"OEBPS/stylesheet.css",
|
"OEBPS/stylesheet.css",
|
||||||
"OEBPS/cover.png",
|
|
||||||
"OEBPS/cover.xhtml",
|
"OEBPS/cover.xhtml",
|
||||||
"OEBPS/front.xhtml",
|
"OEBPS/front.xhtml",
|
||||||
"OEBPS/in-this-issue.xhtml",
|
"OEBPS/in-this-issue.xhtml",
|
||||||
@@ -197,6 +196,11 @@ mod tests {
|
|||||||
"missing {entry} in {edition:?}"
|
"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.
|
// No leftover temp file.
|
||||||
assert!(
|
assert!(
|
||||||
!dir.path()
|
!dir.path()
|
||||||
|
|||||||
@@ -7,17 +7,15 @@
|
|||||||
feed reader: entries are deduplicated, read in full, weighed against social
|
feed reader: entries are deduplicated, read in full, weighed against social
|
||||||
proof, then scored, sectioned and introduced by a language model.
|
proof, then scored, sectioned and introduced by a language model.
|
||||||
</p>
|
</p>
|
||||||
<dl class="colophon-facts">
|
<p class="fact-line"><strong>Issue:</strong> No. {{ issue_number }} · {{ display_date }}</p>
|
||||||
<dt class="fact-key">Issue</dt><dd class="fact-value">No. {{ issue_number }} · {{ display_date }}</dd>
|
<p class="fact-line"><strong>Generated:</strong> {{ generated_at }}</p>
|
||||||
<dt class="fact-key">Generated</dt><dd class="fact-value">{{ generated_at }}</dd>
|
<p class="fact-line"><strong>Curation model:</strong> {{ model }}</p>
|
||||||
<dt class="fact-key">Curation model</dt><dd class="fact-value">{{ model }}</dd>
|
<p class="fact-line"><strong>Entries considered:</strong> {{ entries_fetched }} from {{ feeds_seen }} feeds</p>
|
||||||
<dt class="fact-key">Entries considered</dt><dd class="fact-value">{{ entries_fetched }} from {{ feeds_seen }} feeds</dd>
|
<p class="fact-line"><strong>Candidates scored:</strong> {{ candidates }}</p>
|
||||||
<dt class="fact-key">Candidates scored</dt><dd class="fact-value">{{ candidates }}</dd>
|
<p class="fact-line"><strong>Articles selected:</strong> {{ article_count }} across {{ section_count }} sections</p>
|
||||||
<dt class="fact-key">Articles selected</dt><dd class="fact-value">{{ article_count }} across {{ section_count }} sections</dd>
|
<p class="fact-line"><strong>Words:</strong> {{ total_words }} · {{ reading_line }}</p>
|
||||||
<dt class="fact-key">Words</dt><dd class="fact-value">{{ total_words }} · {{ reading_line }}</dd>
|
<p class="fact-line"><strong>Token cost:</strong> {{ cost_usd }}</p>
|
||||||
<dt class="fact-key">Token cost</dt><dd class="fact-value">{{ cost_usd }}</dd>
|
<p class="fact-line"><strong>Generator:</strong> {{ generator_version }}</p>
|
||||||
<dt class="fact-key">Generator</dt><dd class="fact-value">{{ generator_version }}</dd>
|
|
||||||
</dl>
|
|
||||||
<p class="attribution">
|
<p class="attribution">
|
||||||
Article text belongs to its authors and publications; excerpts and links are
|
Article text belongs to its authors and publications; excerpts and links are
|
||||||
provided for personal reading. Comment excerpts belong to their posters.
|
provided for personal reading. Comment excerpts belong to their posters.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.xhtml" %}
|
{% extends "base.xhtml" %}
|
||||||
{% block body_class %}cover-page{% endblock %}
|
{% block body_class %}cover-page{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="cover-image"><img src="cover.png" alt="{{ alt }}"/></div>
|
<div class="cover-image"><img src="{{ cover_href }}" alt="{{ alt }}"/></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -178,11 +178,15 @@ p.comment-line {
|
|||||||
margin: 0 0 0.35em 0;
|
margin: 0 0 0.35em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
dt.fact-key {
|
.world-summary {
|
||||||
font-weight: bold;
|
margin: 0.2em 0 0.45em 0;
|
||||||
margin-top: 0.35em;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
dd.fact-value {
|
.world-overview {
|
||||||
margin: 0 0 0 0.8em;
|
margin-bottom: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-line {
|
||||||
|
margin: 0 0 0.35em 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,16 +275,20 @@ blockquote.comment blockquote.comment {
|
|||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.world-summary {
|
||||||
|
margin: 0.2em 0 0.45em 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-overview {
|
||||||
|
margin-bottom: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
.attribution {
|
.attribution {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colophon-facts dt {
|
.fact-line {
|
||||||
font-variant: small-caps;
|
margin: 0 0 0.35em 0;
|
||||||
margin-top: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon-facts dd {
|
|
||||||
margin: 0 0 0 1em;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@
|
|||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! Miniflux ingest → dedupe → extraction → persist → social enrichment
|
//! Miniflux ingest → dedupe → extraction → persist → social enrichment
|
||||||
//! → pre-filter → LLM scoring → selection → comments → world briefing
|
//! → pre-filter → LLM scoring → selection → comments → editorial
|
||||||
//! → editorial → EPUB build (standard + X4) → XTC → publish → report
|
//! → world briefing → EPUB build (standard + X4) → XTC → publish → report
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|||||||
+20
-11
@@ -2,8 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
|
//! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment
|
||||||
//! ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ world briefing
|
//! ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial
|
||||||
//! ─▶ editorial ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
|
//! ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Failure policy (notes §3):
|
//! 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.counts.discussions = comments::fetch_all(&http, &mut lineup.picks).await as i64;
|
||||||
report.timings.record("comments", elapsed_ms(stage));
|
report.timings.record("comments", elapsed_ms(stage));
|
||||||
|
|
||||||
// --- Stage 9: world briefing (§3.8) — non-fatal by construction ---
|
// --- Stage 9: editorial (§3.6 C) ---
|
||||||
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) ---
|
|
||||||
let stage = Timestamp::now();
|
let stage = Timestamp::now();
|
||||||
let editorial = match curator.editorial(&lineup).await {
|
let editorial = match curator.editorial(&lineup).await {
|
||||||
Ok(editorial) => editorial,
|
Ok(editorial) => editorial,
|
||||||
@@ -444,6 +436,23 @@ async fn run_stages(
|
|||||||
apply_summaries(&mut lineup, &editorial);
|
apply_summaries(&mut lineup, &editorial);
|
||||||
report.timings.record("editorial", elapsed_ms(stage));
|
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) ---
|
// --- Stage 11: assemble the issue (§3.10) ---
|
||||||
let issue_number = db
|
let issue_number = db
|
||||||
.next_issue_number(date)
|
.next_issue_number(date)
|
||||||
|
|||||||
+23
-2
@@ -399,8 +399,29 @@ pub struct WorldBriefing {
|
|||||||
pub date: Date,
|
pub date: Date,
|
||||||
/// Portal URL the content came from (also used for CC BY-SA attribution).
|
/// Portal URL the content came from (also used for CC BY-SA attribution).
|
||||||
pub source_url: String,
|
pub source_url: String,
|
||||||
/// Sanitized `<ul>`-style markup of the day's events.
|
/// Optional synthesized overview of the completed day's events.
|
||||||
pub body_html: String,
|
pub overview: Option<String>,
|
||||||
|
/// Categories in portal order, including every nested list item.
|
||||||
|
pub sections: Vec<WorldBriefingSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WorldBriefingSection {
|
||||||
|
pub title: String,
|
||||||
|
pub events: Vec<WorldEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub children: Vec<WorldEvent>,
|
||||||
|
/// LLM enrichment, attached only to leaf news statements.
|
||||||
|
pub summary: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserved section name for [`WorldBriefing`] — never offered to the LLM (§3.6).
|
/// Reserved section name for [`WorldBriefing`] — never offered to the LLM (§3.6).
|
||||||
|
|||||||
+679
-235
File diff suppressed because it is too large
Load Diff
+52
-4
@@ -16,6 +16,15 @@ fn contains_entry(zip: &[u8], name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read one entry out of the archive, inflating it.
|
/// Read one entry out of the archive, inflating it.
|
||||||
|
fn read_entry_bytes(zip: &[u8], name: &str) -> Vec<u8> {
|
||||||
|
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 {
|
fn read_entry(zip: &[u8], name: &str) -> String {
|
||||||
use std::io::Read as _;
|
use std::io::Read as _;
|
||||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).expect("zip opens");
|
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_eq!(&zip[30..38], b"mimetype");
|
||||||
assert!(contains_entry(&zip, "OEBPS/art-1001.xhtml"));
|
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("<meta name=\"cover\" content=\"cover-image\"/>"),
|
||||||
|
"{opf}"
|
||||||
|
);
|
||||||
|
assert!(cover_page.contains("src=\"cover.jpg\""), "{cover_page}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Both editions land in the same BookOrbit library, which lists books by
|
/// 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] {
|
for opf in [&standard_opf, &x4_opf] {
|
||||||
assert!(opf.contains("belongs-to-collection"), "{opf}");
|
assert!(opf.contains("belongs-to-collection"), "{opf}");
|
||||||
assert!(opf.contains("<dc:date>2026-08-15</dc:date>"), "{opf}");
|
assert!(opf.contains("<dc:date>2026-08-15</dc:date>"), "{opf}");
|
||||||
|
assert!(opf.contains("<dc:language>en</dc:language>"), "{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"
|
"/tests/fixtures/wikipedia_current_events.html"
|
||||||
))
|
))
|
||||||
.expect("fixture");
|
.expect("fixture");
|
||||||
let body = world::extract_events(&html).expect("events");
|
let sections = world::extract_events(
|
||||||
assert!(body.contains("<li>"));
|
&html,
|
||||||
assert!(!body.contains("<a "));
|
"https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_14",
|
||||||
|
)
|
||||||
|
.expect("events");
|
||||||
|
assert_eq!(sections.len(), 3);
|
||||||
|
assert_eq!(sections[0].events[0].children.len(), 1);
|
||||||
|
assert!(!sections[0].events[0].links.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colophon_facts_are_x4_safe_distinct_paragraphs() {
|
||||||
|
let issue = fixtures::issue();
|
||||||
|
for edition in [Edition::Standard, Edition::X4] {
|
||||||
|
let (_dir, _, zip) = build_edition_to_bytes(&issue, edition);
|
||||||
|
let colophon = read_entry(&zip, "OEBPS/colophon.xhtml");
|
||||||
|
assert_eq!(colophon.matches("<p class=\"fact-line\">").count(), 9);
|
||||||
|
assert!(!colophon.contains("<dl"));
|
||||||
|
assert!(!colophon.contains("<dt"));
|
||||||
|
assert!(!colophon.contains("<dd"));
|
||||||
|
assert!(colophon.contains("<strong>Issue:</strong>"));
|
||||||
|
assert!(colophon.contains("<strong>Generator:</strong>"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user