Skip to content

feat: configurable curation engine (Claude or local model)#11

Open
mhellevang wants to merge 11 commits into
falense:mainfrom
mhellevang:feat/local-curation-engine
Open

feat: configurable curation engine (Claude or local model)#11
mhellevang wants to merge 11 commits into
falense:mainfrom
mhellevang:feat/local-curation-engine

Conversation

@mhellevang

@mhellevang mhellevang commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

What

Two related changes to the Curate stage:

  1. An opt-in, agent-free curation engine that runs the whole OpenPaper
    pipeline with a local model via Ollama — selectable with
    engine: local in .openpaper/config.yaml.
  2. The editorial arithmetic that engine relies on is now a shared core
    (curation_core.py), so the Claude engine can opt into the same
    deterministic selection
    with ranking: deterministic — Claude scores
    relevance and writes the prose, but the slate and role tiers come from the
    same reproducible code.

The defaults are unchanged: engine: claude + ranking: agent is exactly
today's freehand skill flow.

Why

Today the Curate stage requires a Claude Code session. A local engine gives
users a private, offline, free option that doesn't depend on Claude Code — at
the cost of some editorial polish. (Ties into #9's interest in making scoring
explicit.)

Once the arithmetic existed, it was a short step to let the Claude flow reuse
it: hand-curation drifts from the project's own caps (e.g. ending up over the
per-source limit), whereas ranking: deterministic enforces them automatically
while keeping Claude's relevance judgement and summaries.

Design: code does the judgment, the model does the prose

A small local model is a poor editor (asked for a holistic score it skews to
surface topicality and undervalues big human-interest stories). So the work is
split along what each side is good at:

  • Deterministic Python (curation_core.py) does all editorial arithmetic —
    interest weights, source/recency bonuses, feedback decay, the 30%/40%/20%
    caps, and role assignment — mirroring curation-guide.md, and is unit-tested.
  • A language model does only the two things that need one: per-article
    semantic matching against the reader's interests, and the role-calibrated
    summaries (with role-length enforced in code).

curation_core.py is engine-agnostic, so the only difference between the two
engines is who scores relevance and writes the summaries — not the caps or
role tiers:

relevance match summaries selection arithmetic
engine: local local model local model curation_core
engine: claude + ranking: deterministic Claude Claude curation_core (via rank.py)
engine: claude + ranking: agent (default) Claude (freehand) Claude Claude (freehand)

Included

  • scripts/curation_core.py — the shared, engine-agnostic arithmetic
    (config + preferences.md parsing, scoring, the caps, role assignment).
  • scripts/curate.py — the engine: local engine: curation_core + the local
    model layer (semantic match, summaries) + edition assembly.
  • scripts/rank.py — the ranking: deterministic ranker for the Claude flow:
    takes Claude-supplied match scores, runs the same pipeline, emits the slate +
    roles. --print-prefs reports the interests and caps to score against.
  • scripts/make_paper.py — agent-free fetch_all → curate → render orchestrator.
    Ends by launching the preview server and opening the finished edition in a
    browser — the same UX as the Claude flow — with --no-serve for headless/cron.
  • scripts/bench.py — benchmark harness for comparing local models on a corpus.
  • tests/test_curate.py + tests/test_rank.py — first unit tests in the repo
    (deterministic curation + the ranker glue). 19 passing.
  • references/local-engine.md + references/config.example.yaml; README and
    SKILL.md notes (incl. the deterministic-ranking flow).

Ingest (fetch_all.py) and Present (render.py/serve.py) are reused — the
local flow now chains serve.py for preview just like the agent does.

Try it

Local engine:

ollama pull gemma4:e4b-it-q4_K_M
printf 'engine: local\nmodel: gemma4:e4b-it-q4_K_M\n' > .openpaper/config.yaml
uv run skills/openpaper/scripts/make_paper.py --data-dir .openpaper

Deterministic ranking under the Claude engine — set ranking: deterministic in
.openpaper/config.yaml; the /openpaper skill then scores relevance and runs
selection through rank.py (see SKILL.md → Stage 2 → Deterministic ranking).

Model notes (benchmarked — see bench.py)

  • gemma4:e4b-it-q4_K_M — recommended. Instruction-tuned; faithful, fluent
    bokmål, ~4 min for a 14-article edition on an M-series Mac. Use an -it
    (instruction-tuned) tag — base gemma4:e4b/e2b ignore the JSON/summary
    instructions and tend to translate articles verbatim.
  • 12B — not recommended. 12b-it-q4_K_M was ~1.8× slower and unreliable
    (empty story bodies, title corruption, untranslated text); 12b-it-q8_0 was
    memory-bound (~12× slower, heavy swap). Bigger is not better here.

Fixes folded in

  • serve.py port fallback now uses errno.EADDRINUSE instead of a
    hardcoded 98 (the Linux value). On macOS EADDRINUSE is 48, so the
    fallback never fired and the server crashed on a busy port — surfaced once
    make_paper.py started auto-launching the server.
  • Headline guard: a small model sometimes echoes the "OpenPaper" masthead
    (mentioned in the reading profile) into a story's title. Such titles are now
    rejected in favour of the real source headline (_bad_title, unit-tested).

Scope / known limits

  • Local editorial judgment is weaker than Claude's; deterministic rules
    compensate but won't match Claude's instinct for the most interesting lead.
    ranking: deterministic likewise can't catch cross-source duplicates or thin
    sources on its own — the Claude flow keeps an editorial override pass on top.
  • Occasional language slips on terse local-model outputs; guards retry once and
    cap length — a per-locale post-check is a follow-up.
  • Out of scope for now: embeddings-based matching (would remove the generative
    model from selection entirely), a feedback-writing loop, and non-Ollama
    runtimes (llama.cpp/MLX).

Happy to iterate on any of this if the direction is welcome.

Add an opt-in, agent-free curation engine that runs the full pipeline with a
local model via Ollama — selectable with `engine: local` in
.openpaper/config.yaml. Default stays `claude` (existing skill flow unchanged).

Design is hybrid: all editorial arithmetic (interest weights, source/recency
bonuses, 30%/40%/20% caps, role assignment) is deterministic Python in
curate.py mirroring curation-guide.md and covered by tests; the local model
only does per-article semantic matching and role-calibrated summaries. That
split is what keeps a small (4B) model reliable.

- scripts/curate.py     local curation engine (config + preferences parsing,
                        scoring, model layer, edition assembly)
- scripts/make_paper.py agent-free fetch -> curate -> render orchestrator
- tests/test_curate.py  first unit tests in the repo (deterministic curation)
- references/local-engine.md + config.example.yaml, README + SKILL.md notes

Ingest (fetch_all.py) and Present (render.py) are reused unchanged.
make_paper.py now ends by launching serve.py --latest after render, so the
local engine opens the finished edition in a browser just like the Claude
flow instead of leaving an HTML file in .openpaper/. Add --no-serve for
headless/cron runs. Points the server at the localised template's assets
when present.

Fixes found while verifying:
- serve.py: use errno.EADDRINUSE for the port fallback (was hardcoded 98,
  the Linux value; on macOS it is 48, so the fallback never triggered and
  the server crashed on a busy port).
- curate.py: guard generated headlines against the model echoing the
  "OpenPaper" masthead from the reading profile; fall back to the real
  source title. Add _bad_title + unit test.
Add bench.py to time the curation pipeline (matching + summaries) across
models and emit editions for side-by-side comparison.

Benchmark result (M-series Mac, 57-article corpus):
- gemma4:e4b-it-q4_K_M ~4.2m, full bodies, fluent bokmål — recommended
- gemma4:12b-it-q4_K_M ~7.5m AND broken at this quant (empty bodies, title
  corruption, untranslated text) — not recommended
- Claude default ~1.8m wall-clock (parallel subagents), best quality

Also: gemma4:e4b is byte-identical to gemma4:e4b-it-q4_K_M (the base tag was
already instruction-tuned), so config now uses the explicit -it tag for
clarity only. Update local-engine.md model guidance accordingly.
…wer)

Tested higher precision to see if it fixed q4's empty/garbled output. On a
48GB M-series Mac it was far worse: ~2.4 tok/s, heavy swap at 32k context,
matching alone ~37 min, and a runaway generation forced an abort. No 12B
build tested is viable; stay on e4b-it-q4_K_M.
Extract the editorial arithmetic (scoring, topic/source/serendipity caps,
role assignment) out of curate.py into curation_core.py so both engines
share one unit-tested implementation instead of drifting apart.

Add rank.py: a deterministic ranker for engine: claude + ranking:
deterministic. Claude scores per-article relevance (its strength), then the
same core pipeline picks the slate and assigns roles — reproducible, and the
caps are enforced automatically instead of by hand. Claude still writes the
summaries. Default ranking: agent keeps the existing freehand flow.

Document the new config key and flow; add tests/test_rank.py.
…ngine

# Conflicts:
#	skills/openpaper/SKILL.md

@falense falense left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jeg tror jeg mangler noen steg for å få dette til å fungere 🤔 Må man kjøre igjennom en gang med Claude først?

Comment thread README.md Outdated

```bash
ollama pull gemma4:e4b
uv run skills/openpaper/scripts/make_paper.py --data-dir .openpaper

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for this to work .openpaper/config.yaml needs to exist

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It fails from a new install when Claude hasnt made the scaffolding

FileNotFoundError: [Errno 2] No such file or directory: '/home/sondre/Repositories/OpenPaper/.openpaper/sources/_base.py'

The engine: local path (make_paper.py) was documented as standalone but
depended on .openpaper/ scaffolding only the Claude setup wizard creates.
From a fresh clone it crashed in deploy_base_module with
FileNotFoundError on sources/_base.py (no sources/ dir), or exited
pointing at a config.yaml that didn't exist.

- make_paper.py now bootstraps on first run: creates the data dirs, a
  config.yaml (engine: local), a starter preferences.md, and installs
  shipped default news fetchers. Idempotent; never overwrites user files.
  An existing engine: claude config still defers to the Claude flow.
- fetch_all.deploy_base_module mkdirs sources/ before copying _base.py.
- ship skills/openpaper/fetchers/news/{bbc,hackernews}.py as defaults.
- docs: README + local-engine.md describe the bootstrap and the
  'run Claude once to tailor sources' path; fix the model name to the
  instruction-tuned gemma4:e4b-it-q4_K_M (base e4b ignores instructions).
make_paper.py now brings up the prerequisites itself so one command works
on a fresh machine with no manual setup: it installs Playwright's Chromium
on first run, and ensures Ollama is running (starting `ollama serve` if
needed) with the configured model pulled before curating. Add --skip-setup
to skip these checks for cron/headless runs where everything is present.

Also trim the README: the 'Curation engine' section was ~40 lines of
internal design detail; reduce it to a tiny 'Run fully local' step (install
Ollama, run one command) that mirrors the Install step, and leave the design,
model notes, and limits to the local engine guide.
It's already the default in make_paper.py, so the local-engine command is
just `uv run skills/openpaper/scripts/make_paper.py`.
_ensure_ollama now returns the server process only when it had to start
one; main() stops exactly that process in a finally (covering success,
error, and Ctrl+C), so a run leaves the machine as it found it. A server
that was already running is never touched. The model unloads on Ollama's
own idle keep_alive either way.
@mhellevang

Copy link
Copy Markdown
Contributor Author

Jeg tror jeg mangler noen steg for å få dette til å fungere 🤔 Må man kjøre igjennom en gang med Claude først?

Whops! Ja jeg hadde ikke gått opp boostrap-løypa 🙈

Nå skal det bare være å kjøre uv run skills/openpaper/scripts/make_paper.py fra en fersk install (gitt at ollama er installert alt), så skal den bootstrappe og rydde opp etter seg etterpå. Men det er ikke like polert som claude-implementasjonen, altså. Ærlig talt så er jeg usikker på om det er verdt å ta det inn i dette prosjektet eller om det egner seg best som et eget prosjekt, men det var uansett en gøy øvelse.

Om du har tid og lyst kan du jo ta en titt og se hvordan det funker med lokal modell på din maskin, om enn bare for å sjekke ut, så kan du føle på det 😄

@mhellevang mhellevang requested a review from falense June 10, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants