Curation v2 step 6: Behind the paper, stats, run report block, lock

Behind-the-paper chapter (behind.xhtml, both editions) built from the run's
StageCounts and candidate_runs near misses; daily-epub stats [--days N];
StageCounts gains knn/feed gates and verdicts_in_prompt, timings split
into summaries + brief, the four-line §15.4 info block logged once per
run and printed by print_report; src/lock.rs flock guard on
<database_path>.lock for generate, profile rebuild, features backfill and
backfill-social; README updated for the new CLI, env vars and costs.

Implemented by a Claude agent from docs/plans/curation-v2-briefs/step6.md;
reviewed against plan §5, §15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 16:21:06 +00:00
co-authored by Claude Fable 5.1
parent 05a74a0dcf
commit d261cd485d
22 changed files with 1583 additions and 113 deletions
+49 -6
View File
@@ -13,7 +13,11 @@ the summaries and *The Brief*. It assembles two EPUB editions (a standard one
and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and
publishes the lot over its own OPDS catalog — which doubles as a
[BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one.
Each article chapter ends with Loved it / Good / Not for me links that feed back into tomorrow's curation.
Each article chapter ends with Loved it / Good / Not for me links that feed back
into tomorrow's curation, and a short *Behind the paper* chapter before the
colophon says what the run considered, how the deep set was admitted, whether
the learned signals were active, the ten highest-utility near misses, and what
it all cost.
Steady-state cost is roughly **$1/day**: $0.050.30 in DeepSeek tokens plus
~$0.500.80 for the Claude editor and a few cents of Voyage AI embeddings, each
@@ -38,6 +42,25 @@ Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enr
Every stage writes to SQLite, so a run is idempotent per date: re-running
`generate --date 2026-08-15` replaces that issue rather than duplicating it.
Only one writer runs at a time: `generate`, `profile rebuild`, `features
backfill` and `backfill-social` take an advisory `flock` on
`<database_path>.lock`, and a second invocation exits with `generate is already
running` (naming whichever command holds it). `serve`, `explain`, `stats`,
`ratings`, `features prune` and `db migrate` never wait on it, and the kernel
releases the lock when the holder exits, however it exits.
Every run ends with a four-line summary in the log and on stdout:
```
curation: 412 considered → 398 eligible → 398 triaged → 120 assessed → 60 shortlisted → 17 selected
admission: triage 60 · interest 20 · knn 12 · exploration 5 · blend 23 · auto 0
preference: 14 rated w/ embeddings → knn 0.35 · feed off · 41 verdicts in prompt
providers: anthropic $0.62 · deepseek $0.11 · voyage $0.02 · total $0.75 · 23m12s
```
The full report (per-stage counts and timings — `embed`, `signals`, `triage`,
`admit`, `assess`, `rank`, `editor`, `summaries`, `brief` among them — and
per-provider usage) is stored on the `runs` row and in `issues.report_json`.
**Failure policy.** Miniflux ingest, SQLite writes, EPUB assembly and publishing
are fatal — without them there is no issue, and the `runs` row records why.
@@ -91,6 +114,7 @@ daily-epub ratings set --article 42 --label loved --note "excellent"
daily-epub ratings clear --url https://example.com/article
daily-epub explain --date YYYY-MM-DD (--article ID | --url URL) [--run-id N]
daily-epub explain --date YYYY-MM-DD --near-misses [N]
daily-epub stats [--days 14] # the evaluation framework, one fact per line
daily-epub features backfill [--days 30] [--rated-only] [--all] [--yes]
daily-epub features prune # stale embeddings + old candidate telemetry
daily-epub backfill-social # re-poll social scores for recent articles
@@ -104,6 +128,8 @@ does not advance the ingest watermark. It prints the lineup and the cost report.
`--skip-embeddings` reads the embedding cache but makes zero Voyage calls.
`--rescore` ignores reusable triage/deep assessments for this run.
`--max-articles N` is a ceiling, never a target: the hard maximum becomes
`min(curation.max_article_count, N)` and the soft target is lowered to fit.
`explain` answers "why was this (not) in the paper" from the `candidate_runs`
row the run persisted for every considered article: the stage it reached and the
@@ -116,6 +142,15 @@ database at all is reported as never ingested (a feed problem, not a ranking
one). `--near-misses` lists the highest-utility articles that were not selected
(by preliminary blend for articles the ranker never reached).
`stats` is the whole evaluation framework, on purpose: for the last `--days`
(14) it prints the issues and articles published, the mean issue size, explicit
ratings by label and per issue, the up/down ratio of rated picks per admitting
retriever (`admitted_by[0]` — triage, interest, knn, exploration, blend,
auto_include), the exploration yield (admitted, selected, rated positively),
cost per day per provider from `runs.provider_costs_json`, and the mean
generation time. Plain text, one fact per line. Tune from it and from reading
the paper; anything more waits for more ratings.
`features backfill` embeds the rated and published articles first (the learned
set), then the standing interests, then — only with `--all` — every other
article first seen in the window. It prints an estimate and asks before spending
@@ -268,6 +303,9 @@ DAILY_EPUB_VOYAGE__API_KEY=…
DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32)
EOF
sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env
# `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` are the two
# keys curation v2 added; the units read them from this file unchanged. Either
# may be left unset: the run then falls back to DeepSeek / cached embeddings.
# publish dirs must exist and be writable by the service user
sudo install -d -o daily-epub -g daily-epub /var/lib/daily-epub/xtc
@@ -436,7 +474,7 @@ DAILY_EPUB_OUT_DIR=./out daily-epub generate --dry-run --skip-llm --max-articles
ls -la ./out # two .epub files
epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors
# open the standard edition in Calibre / KOReader: cover, The Brief,
# In This Issue, sections, discussions, colophon; TOC depth 2
# In This Issue, sections, discussions, Behind the paper, colophon; TOC depth 2
# 4. Now with DeepSeek and Claude, still not publishing
daily-epub generate --dry-run --out ./out --max-articles 6
@@ -460,12 +498,13 @@ curl -s https://daily.hallada.net/issues.json | jq '.[0]'
sqlite3 /var/lib/daily-epub/daily-epub.db 'select * from rating_events order by event_at desc;'
# 8. Watch cost and quality for a week
sqlite3 /var/lib/daily-epub/daily-epub.db \
'select date, status, entries_fetched, candidates, selected, cost_usd from runs order by id desc limit 7;'
daily-epub stats --days 7
daily-epub explain --date $(date +%F) --near-misses
```
Tune `curation.ranking.deep_keep`, `target_article_count` and `curation.always_include_feeds`
from what you see in step 8.
from what you see in step 8, and read the paper's *Behind the paper* chapter
each morning: it is the same numbers, on the device.
### Troubleshooting
@@ -496,7 +535,10 @@ The crate is a library plus a thin binary, so tests drive the pipeline directly.
`tests/e2e_pipeline.rs` is the capstone: synthetic entries → dedupe → offline
extraction → signals → triage → admission → selection (both the `--skip-llm` route and a
`MockBackend` DeepSeek route) → editorial → both EPUB editions → publish → OPDS
and database rows, with no network access anywhere.
and database rows, with no network access anywhere. `tests/m4_epub.rs` covers
the rendered chapters, including *Behind the paper* in both editions; the
`stats` and lock tests live next to their modules (`curate/telemetry.rs`,
`lock.rs`).
### Layout
@@ -516,6 +558,7 @@ images/ article images comments.rs discussion chapters
encode re-encode publish.rs BookOrbit + XTC
embed into the page server.rs ratings, OPDS
html.rs markup helpers db.rs SQLite
lock.rs one writer at a time (flock on <database_path>.lock)
```
Two modules are worth knowing about before you go looking for their contents.