Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
34 KiB
First-class interests: a table, a page, categories, links, and a rating-driven weight
Date: 2026-09-12
Repository: thallada/the-daily-epub
Status: implementation plan, ready to execute
Builds on: docs/plans/2026-09-02-personalized-curation-v2.md (§8 profile and interests, §9 cheap signals, §12 blend and utility), docs/plans/2026-09-03-web-dashboard.md (page conventions), docs/plans/2026-09-07-feed-discovery.md (the most recent page + job + CLI addition; copy its shapes)
Written for a fresh implementation agent. Facts about this repo were checked against main (e82f7b0) on 2026-09-12. Nothing here depends on an external service beyond the LLM providers and Voyage that the pipeline already uses.
1. Goal
Today the reader's ~230 standing interests live in a Scour OPML export (data/scour-interests.opml) plus an optional ## Interests section of profile.md. They are parsed on every run, embedded, matched against each day's articles (signals::interest_matches), grouped for the system prompt by a hand-written keyword table (profile/themes.rs), and — since the last commit — shown to the reader as the Matches: line under every article header. They cannot be added from the dashboard, ratings never touch them, and nothing links from an interest to the articles that matched it.
This plan makes interests a first-class record:
- A database table is the only source of interests. The OPML and the profile's
## Interestssection become one-time import inputs. - An Interests dashboard page lists them with category, weight, and match counts; adds new ones; filters by category; changes a category by hand; deletes.
- Categories are kept (the grouped view the operator likes) and assigned by an LLM in one daily batch, only when uncategorized interests exist, with a manual Categorize now button.
- Every interest name in the web UI is a link to the Articles page filtered (and sorted) to the articles that matched it, with a cut-off so unrelated articles never appear. The Articles page gains an interest filter and an interests column.
- Ratings nudge interests. Each explicit rating credits the interests the article matched, scaled by how strongly it matched; the resulting per-interest weight is shown on the Interests page (default sort: highest first) and feeds curation as one bounded signal, so diversity is preserved.
- Backfill is two CLI commands the operator runs once.
2. Verified facts
Interests today
profile::parse_interests(opml)reads<outline text="…">names;parse_profile_strstrips## Interestsfromprofile.mdand returns its lines;union_interestsdedupes case-insensitively (src/curate/profile/mod.rs:36–150).load_standing_interests(opml, profile)is called frompipeline::prepare_features(src/pipeline.rs:946),embedding::plan_backfill(src/curate/embedding.rs:901), andprofile::load_or_build/rebuild(throughprompt_inputs).config.interests_opmlis referenced in 20 places insrc/(mostly tests that write a temp OPML),config.example.toml:29, README (lines 103 and 369), anddocs/runbooks/curation-v2-migration.md.- The system prompt's "Standing interests" section groups names with
profile::group_into_themes(src/curate/profile/themes.rs): twelve keyword themes plus "Other standing interests", deterministic, sorted. The profile dashboard page renders the same grouping (src/web/dashboard/profile.rs:249, templatedashboard/profile.html:42). interest_embeddings(migration 0002) is keyed by the interest name;EmbeddingService::interests(&[String])returns cached vectors and fetches misses (src/curate/embedding.rs:743). A new name is embedded automatically the next time it is passed.signals::interest_matches(article_embeddings, interest_embeddings)(src/curate/signals.rs:492) computes cosine per interest × article, z-scores each interest across the day's embedded articles (std floored at 1e-3; raw top-1 cosine fallback under 30 articles), keeps the topRECORDED_TOP = 3per article asTopInterest { name, z, cos }, and scores0.7·z₁ + 0.3·mean(top-3 z).signals.top_interestsis serialized intocandidate_runs.signals_json(telemetry::serialize_signals), copied intoPick.top_interestsas bare names by the editor (src/curate/editor.rs:598), and rendered as the Matches line bychapters::understanding→_understanding.html(web) andchapter.xhtml/in_this_issue.xhtml(EPUB). Prompts list interests withz ≥ 1.5as "matches" (triage.rs:135,assess.rs:192,editor.rs:159).- Interest names also appear on: the dashboard article detail (
dashboard/article.html:61, "Top interests" table),_signals_table.html:5(runs and article history), and the Feeds page "Why" badges (dashboard/feeds.html:14, fromdiscovery::why). - Daily volume: ~365 entries → a few hundred articles per run; 512-dim
voyage-4-litevectors;embedding_retention_days = 120, so the embedding cache holds on the order of 30–50 k vectors (≈2 KB each). Scanning the cache per web request is not an option; a per-run cosine pass is milliseconds (§9.1 of the curation plan).
Ratings and the learned signals
rating_eventsis append-only;Db::current_ratings(lookback_days) -> Vec<RatedArticle>returns the latest explicit verdict per article withvalue(loved1.0,good0.35,not_for_me−1.0,slopperfeedback.slop_value) andevent_at(src/db.rs:713). Ratings are written byweb::rate::post(dashboard and issue pages),rate::record_explicit(CLI), andimports::run.signals::PreferenceState::load(src/curate/signals.rs:238) builds the run's learned state from current ratings: decayed weightsvalue × 0.5^(age/half_life), kNN over rated embeddings, and feed/author affinity as Beta-smoothed rates(up+1)/(up+down+2)with a gategate(n, feed_floor=15, feed_full=40). Every cheap signal isOption<f64>, percentile-normalized over the eligible set (normalize,PERCENTILE_SIGNALS), blended with renormalized weights (preliminary_blend;rank::calculate_utility_forfor the deep set). Absent is never zero. The slop-author factor scales both blends. Diversity is enforced downstream byrank::shortlist(cluster threshold 0.85,per_cluster_cap = 2,utility_protected = 10) and by the editor prompt.- Signal names are enumerated in:
signals::PERCENTILE_SIGNALS,Signals::raw,preliminary_blend's candidate list,rank::calculate_utility_for's weighted list,telemetry::serialize_signals(raw list) andRENDERED_SIGNALS,dashboard::SIGNAL_NAMES,config::{PreliminaryWeights, UtilityWeights}, andsettings::SETTINGS_HELP. A new signal touches all of them. - The Ratings dashboard page computes each verdict's feed credit on the fly with a pure function (
ratings::contribution) rather than storing it. Follow that precedent.
Dashboard, jobs, CLI, config
- One submodule per page group under
src/web/dashboard/, each withroutes(), merged indashboard::router(); admin gating is applied by the caller. Nav tabs are hard-coded insrc/web/templates/layout.html(keyed onpage.active_nav); overview tiles indashboard/overview.html. POST → flash → redirect viajobs::set_flash.Page::is_admin()is available in every template. Pager partialdashboard/_pager.html; client-side row filterdata-table-filter. - The Articles list (
src/web/dashboard/articles.rs) buildsARTICLE_INNER(articles ⨝ best entry ⨝ latestcandidate_runsrow ⨝ assessments, with correlated subqueries for rating and publication) and wraps it inSELECT * FROM (…) x WHERE 1=1 {clauses} ORDER BY {sort}. Filters are allow-listed; sorts come fromARTICLE_SORTS;Pager::new(pagination, path, &filters.params())round-trips them. - Jobs are a fixed catalogue (
jobs::Job,CATALOGUE,parse,name,description,takes_lock,dangerous) run asdaily-epub-job@<name>.serviceand dispatched inmain::run_job. The profile page startsprofile-rebuildwith a plain form posting to/dashboard/jobs/profile-rebuild. The only daily entry point is thedaily-epub-generate.timer(05:30 America/New_York). pipeline::build_llms(src/pipeline.rs:1133) builds the taste-profile prompt, the clients, and runs the weekly learned-adjustments rebuild, then rebuilds the clients with the new prompt. An LLM step that must precede the prompt goes here.- Config sections are
#[serde(deny_unknown_fields, default)]; an unknown top-level key makesConfigfail to load. The settings page has a hard-codedGROUP_ORDER,PATH_KEYS, andSETTINGS_HELP, with a test over the section list. Migrations:sqlx::migrate!("./migrations"); latest is0012_article_publication.sql, so the new file is0013_interests.sql.db.rshas a migration test asserting a table list (src/db.rs:1720).
3. Options considered
3.1 Where "articles matching interest X" comes from
| Option | How | Verdict |
|---|---|---|
| A. Compute on request | Load the interest vector and every cached article embedding, dot, sort. | Rejected. 30–50 k × 2 KB per request; the dashboard is 1–7 ms today and should stay there. |
B. Query candidate_runs.signals_json with json_each |
The top-3 names are already persisted per run. | Rejected. A JSON scan over every telemetry row (hundreds of thousands, pruned at 180 days) per request, no index, and rows vanish with telemetry retention. |
| C. A junction table written by the signals stage | article_interests(article_id, interest_id, cos, z): the same top-3 the run already computes, one row each, indexed by interest. The filter is an indexed join. |
Chosen. ~1 k rows/day, zero extra computation, and the backfill is one pass over cached embeddings. |
3.2 How an interest weight is computed and stored
| Option | How | Verdict |
|---|---|---|
| A. Stored counters updated on every rating event | Add up/down columns to interests; the rating handlers, CLI, and importer bump them; write a backfill migration script. |
Rejected. Four write paths to keep in sync, decay cannot be stored (it is a function of now), and it duplicates the ratings history that already exists. |
| B. Derived on the fly from current ratings × matches | One pure function over current_ratings and their article_interests rows, exactly like feed affinity. The run computes it in PreferenceState::load; the Interests page computes it on render. |
Chosen. No new write path, always current, decay and lookback for free, backfill is "make sure rated articles have match rows". ≤ a few hundred ratings × 3 rows: microseconds. |
| C. Ask the weekly learned-adjustments rebuild to write per-interest weights | An LLM judges the rating history per interest. | Rejected. Non-deterministic, weekly, and the numbers would not be explainable. The existing prose rebuild already sees the ratings. |
3.3 How the weight enters curation
| Option | How | Verdict |
|---|---|---|
A. A new bounded cheap signal, affinity |
Per article: match-strength-weighted mean of its matched interests' weights; percentile-normalized; gated on rating count; small configured weight in the preliminary blend and the utility. | Chosen. Fits the existing design (absent ≠ zero, renormalized weights, explain shows it), and its influence is capped at its weight share, so one runaway interest cannot dominate. Diversity machinery downstream is untouched. |
| B. Multiply each interest's z by its weight before the top-3 is taken | Changes which interests appear as matches. | Rejected. Entangles "what does this article match" with "what does he like", and the Matches line would drift with ratings. |
| C. Annotate the prompt's Standing interests with ↑/↓ | Cheap and the LLM would use it. | Deferred (§9). Worth adding once the weights have a few weeks of ratings behind them; it is a five-line change on top of this plan. |
| D. A new deep-set admission retriever by affinity | Like the interest and knn quotas. |
Rejected. More slots for the same signal; the blend fill already admits high-affinity articles. |
3.4 Categories
| Option | Verdict |
|---|---|
A. interests.category TEXT NULL; the category set is the distinct values |
Chosen. No FK, no second page, renaming is an UPDATE. |
B. A separate interest_categories table with FK |
Rejected: a table with one meaningful column. |
C. Keep the keyword table in themes.rs |
Rejected as the source of truth (the user wants DB-tracked interests and LLM categorization), but kept for the one-time import so the current grouping survives unchanged. |
3.5 When the categorizer runs
| Option | Verdict |
|---|---|
A. Inside generate, before the prompt is built, only when uncategorized interests exist; plus a catalogue job for the button |
Chosen. The morning timer is the only daily trigger that exists; a run without new interests spends nothing. |
| B. Its own systemd timer | Rejected: another unit to install for a call that takes seconds. |
| C. Synchronously in the Add handler | Rejected: an LLM call in a request path, and the user asked for a daily batch. |
4. Design decisions (settled)
| Topic | Decision |
|---|---|
| Match rule (one definition everywhere) | An interest matches an article when it is among the article's top three interests by z and z ≥ MATCH_MIN_Z = 1.0. interest_matches applies this when it truncates, so signals.top_interests, the Matches line, the stored rows, the Articles filter, and the interest weights all agree. The score formula is unchanged (computed before the cut). The prompts keep their stricter z ≥ 1.5 for "matches interests". |
| Stored rows | article_interests(article_id, interest_id, cos, z, run_id), upserted per eligible article per run (the same article can be eligible on consecutive days; the latest run wins; cosine is stable, z is that day's). Rows are never pruned (≈40 bytes each, ~1 k/day). |
| Link target and filter key | /dashboard/articles?interest=<name>: names are unique (case-insensitive), human-readable, and Pick.top_interests already carries names, so no id has to travel through issue_json. The handler resolves the name to an id; an unknown name yields an empty list, never an error. When interest is set and sort is absent, the sort defaults to match (cosine descending). |
| Who sees links | The dashboard is admin-only, so the Matches line links only for admin viewers (page.is_admin()); readers and anonymous visitors see plain text as today. The EPUB never links. |
| Interest weight | Beta-smoothed rate over decayed, strength-scaled credits (§5.2): (up + 1) / (up + down + 2), in (0, 1), 0.5 = no information. Shown with two decimals plus up, down, and the number of rated matches. |
| Match strength | s = clamp(z / 3, 0, 1): a z of 3 credits the full rating, a bare match (z = 1) a third. |
| Curation signal | affinity (§5.3): signed, centred on zero, gated on the number of ratings that credited at least one interest (affinity_floor = 15, affinity_full = 40, same shape as the feed gate). Default weights: preliminary 0.10 (taken from interest 0.35→0.30 and social 0.10→0.05), utility 0.05 (taken from knn 0.15→0.10). |
| Diversity | Guaranteed by construction: the signal's share of the blend is its configured weight (≤ 10 % / 5 %), percentile normalization caps a favourite interest's articles at percentile 1.0 of that one signal, Beta smoothing means a single loved moves a weight from 0.50 to at most 0.67, ratings decay with the 60-day half-life, and rank::shortlist's per-cluster cap and the editor's diversity instructions are unchanged. No new cap is needed. |
| Source of truth | The interests table. interests_opml is removed from config; profile.md's ## Interests section is still stripped by the parser but no longer read (the importer consumes it once; the profile page says so). |
| Prompt grouping | "Standing interests" groups by interests.category (sorted by category name, members sorted case-insensitively); NULL renders under the existing label "Other standing interests". themes.rs is used only by the importer. |
| Categorizer | One JSON call on the bulk provider over every uncategorized interest, given the existing category list; may create a category only when none fits. Unassigned names stay NULL and are retried the next day. Also the catalogue job interests-categorize behind the Categorize now button. |
| Interests page actions | Add (name + optional category), change category (per-row select + Save), Delete (confirm; cascades match rows, deletes the cached embedding). No rename (delete + add). |
| Backfill | daily-epub interests import (OPML + profile section → rows, categorized by the keyword table) and daily-epub interests backfill (match rows for every cached embedding). Weights need no backfill: they are derived. |
| Config | No new section. [curation.ranking] gains affinity_floor, affinity_full; the two weight tables gain affinity. |
5. The numbers
5.1 Match rows
In prepare_features (src/pipeline.rs), after signals::compute_all and before the candidates are handed on: for every candidate with non-empty signals.top_interests, upsert (article_id, interest_id, cos, z, run_id). One transaction, INSERT … ON CONFLICT(article_id, interest_id) DO UPDATE SET cos, z, run_id. Names map to ids through the interests rows loaded at the top of the stage (the same rows whose names go to service.interests). Best effort: a failure is a report warning, never a failed run.
5.2 Interest weight (pure, interests::rates)
Inputs: current_ratings(rating_lookback_days) and the article_interests rows of those articles.
for each current rating r on article a (value v_r, decay d_r = 0.5^(age_days / half_life_days))
for each match row (a, i, z):
s = clamp(z / 3, 0, 1)
credit = v_r × d_r × s
up_i += max(credit, 0)
down_i += max(−credit, 0)
n_i += 1
weight_i = (up_i + 1) / (up_i + down_i + 2) # 0.5 when n_i = 0
cleared verdicts are already excluded by current_ratings; slop carries feedback.slop_value like everywhere else. A rating is attributable to interests when it credits at least one interest; that count drives the gate.
5.3 The affinity signal (pure, in PreferenceState)
matched = the article's top_interests with n_i > 0
affinity = Σ s_i × (weight_i − 0.5) / Σ s_i absent when matched is empty or the gate is 0
gate = gate(attributable_interest_ratings, affinity_floor, affinity_full)
The raw value lives in [−0.5, 0.5]; it is percentile-normalized with the other cheap signals, weighted by weights.preliminary.affinity × gate in the blend and weights.utility.affinity × gate in the utility. explain and _signals_table.html show it like any other signal. The once-per-run preference log line gains affinity gate … (n=…).
6. Data model — migrations/0013_interests.sql
CREATE TABLE interests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
category TEXT, -- NULL until categorized
created_at TEXT NOT NULL,
categorized_at TEXT
);
CREATE INDEX idx_interests_category ON interests(category);
CREATE TABLE article_interests (
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
interest_id INTEGER NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
cos REAL NOT NULL,
z REAL NOT NULL,
run_id INTEGER, -- NULL for backfilled rows
PRIMARY KEY (article_id, interest_id)
);
CREATE INDEX idx_article_interests_interest ON article_interests(interest_id, cos DESC);
interest_embeddings stays keyed by name (no migration): a deleted interest also deletes its embedding row; a renamed one is a new interest.
7. Implementation steps
Sizes are rough line counts including tests. Steps 1–3 must be in order; 4–8 can proceed in parallel after 3; 9 last.
-
Migration +
src/interests.rs(~350). The table above; the db-list test indb.rsgains both tables. Module (followingdiscovery.rs: data access, pure functions, and the LLM step in one file):Interest { id, name, category, created_at, categorized_at };list(db) -> Vec<Interest>(ordered by name),add(db, name, category, now) -> Result<id, Duplicate>(trimmed, 1–80 chars, unique case-insensitive),set_category(db, id, Option<category>, now),delete(db, id)(alsoDELETE FROM interest_embeddings WHERE interest = name),names(db) -> Vec<String>,grouped(db) -> Vec<(String, Vec<String>)>(for the prompt),uncategorized(db).replace_matches(db, run_id, &[(ArticleId, &[TopInterest])], names→ids);matches_for_articles(db, ids) -> Vec<MatchRow { article_id, interest_id, name, cos, z }>;match_counts(db) -> HashMap<interest_id, i64>.pub fn rates(ratings: &[(ArticleId, value, decay)], rows: &[(ArticleId, interest_id, z)]) -> (HashMap<interest_id, Rate { up, down, n }>, attributable)per §5.2, andRate::weight().- Tests: uniqueness is case-insensitive;
ratesmatches hand-checked numbers (a loved z=3 → up 1.0; a good z=1.5 → up 0.175; a not-for-me z=0.9 → nothing, below the cut); delete cascades;groupedputsNULLlast under "Other standing interests".
-
Signals (~250). In
signals.rs:MATCH_MIN_Z = 1.0applied insideinterest_matchesafter truncation;Signals.affinity: Option<f64>;"affinity"inPERCENTILE_SIGNALS,Signals::raw,preliminary_blend(gateaffinity_gate);PreferenceStategainsinterest_rates: HashMap<String, Rate>(keyed by name —TopInterestcarries names),affinity_gate,attributable_interest_ratings,fn affinity(&self, top: &[TopInterest]) -> Option<f64>, loaded inloadfrominterests::matches_for_articles(rated ids)and exposed onPreferenceSummaryand inlog.rank::calculate_utility_foradds("affinity", configured.affinity, candidate.signals.affinity_gate).telemetry:serialize_signalsraw list andRENDERED_SIGNALS;render_explainprints it with the others.dashboard::SIGNAL_NAMESand_signals_table.htmlpick it up automatically once the name is in the list. Config:RankingConfig { affinity_floor: 15, affinity_full: 40 },PreliminaryWeights { affinity: 0.10, interest: 0.30, social: 0.05 },UtilityWeights { affinity: 0.05, knn: 0.10 },SETTINGS_HELPlines,config.example.toml, README table. Tests: the top-3 cut drops a z=0.4 third interest; affinity is absent under the gate and with no rated interests; a candidate matching a 0.8-weight interest outranks an otherwise identical one matching a 0.3-weight interest in the blend; blend weights still renormalize to 1. -
Replace the OPML/profile plumbing (~300, mostly deletions and test edits).
profile::load_standing_interests,parse_interests,union_interests, andprompt_inputs' OPML argument go;build,load_or_build,rebuild, andweekly_rebuild_if_duetakegrouped: Vec<(String, Vec<String>)>frominterests::grouped(db)instead ofinterests: &[String]+group_into_themes.pipeline::prepare_featuresandembedding::plan_backfilltake names frominterests::names(db).themes.rsstays but is only referenced by the importer (step 8);pub use themes::group_into_themesis removed from the profile module. RemoveConfig.interests_opml(struct,Default,config checkline, thedeny_unknown_fieldsimplication is a rollout note in §10),settings::PATH_KEYS/SETTINGS_HELP,config.example.toml, README (lines 103, 369), and the runbook mention. Every test that writes a temp OPML instead inserts rows withinterests::add. The profile page loses the OPML card and the "Extracted## Interestslines" preview; in their place one line: "N standing interests in M categories — manage them on the Interests page", and the editor note says the## Interestssection is ignored. -
Match rows in the run (~80).
prepare_featureswrites them per §5.1 (timing folded into the existingsignalstiming; countinterest_matcheson the report counts is optional — skip unless free). -
Articles page (~200).
ArticlesQuery.interest: Option<String>→ArticleFilters.interest: Option<(i64, String)>resolved by name (case-insensitive) infrom_query's caller (it needs the db; resolve inlistbefore building filters, or makefrom_queryasync — pick the former). When set:ARTICLE_INNERgainsJOIN article_interests ai ON ai.article_id = a.id AND ai.interest_id = ?(inside the inner query soidx_article_interests_interestdrives it; the join is a{interest_join}placeholder that is empty otherwise) and exposesai.cos AS match_cos;ARTICLE_SORTSgains("match", "x.match_cos DESC, x.id DESC"), which is the default wheninterestis set andsortis absent, and is ignored (falls back tofirst_seen) when it is not. Filter UI: a<select name="interest">over all interest names (blank = any), populated frominterests::list. New column interests: for the page's ≤ 50 rows, one query throughmatches_for_articles, grouped per article, rendered as badges linking to?interest=<name>(ordered by z desc).params()round-tripsinterestandsort=match. Test: a seeded match row is found by the filter, an unknown name yields an empty table (200), and the column links. -
Interests page (~450).
src/web/dashboard/interests.rs+dashboard/interests.html; routesGET /dashboard/interests,POST /dashboard/interests(add),POST /dashboard/interests/{id}/category,POST /dashboard/interests/{id}/delete. Nav tab Interests after Ratings; overview tile "N interests · M uncategorized". Page: add form (name, category select with a blank "let the categorizer decide" option), category filter tabs or select (?category=<name>and?category=uncategorized), sort select (weightdefault,name,matches,added), thedata-table-filterrow filter, no pager (≤ a few hundred rows). Columns: interest (link →/dashboard/articles?interest=<name>), category (link →?category=), weight (2 decimals, tabular;—when n = 0), up, down, rated matches n, matched articles (count), added (format_time), actions (category select + Save; Delete withconfirm). Weights come frominterests::ratesovercurrent_ratings(rating_lookback_days)computed on render; the header says which lookback and half-life it uses and shows the affinity gate state ("affinity gate 0.35 · 22 of 40 attributable ratings"), mirroring the Ratings page. Categorize now button posts to/dashboard/jobs/interests-categorize(the existing generic job route), shown enabled whenjobs_enabledand disabled with the manual command otherwise, with the uncategorized count next to it. Flash messages for add/duplicate/deleted. Tests: page renders with weights sorted descending; add rejects a duplicate differing only in case; delete removes match rows and the embedding row; routes are admin-only. -
Links everywhere (~120).
chapters::Understanding.interestsbecomesVec<InterestRef { name, href }>(href = "/dashboard/articles?interest=" + encode_component(name)) withinterests_line()for the EPUB templates;_understanding.htmlrenders{% for %}…{% if page.is_admin() %}<a href>…joined by·. Dashboard article detail "Top interests" table,_signals_table.html, and the Feeds page "Why" badges link the same way. Existing tests assertingMatches: Filesystems · Rustkeep passing for non-admin views; add one admin assertion. -
Categorizer + job + CLI (~350).
interests::categorize(config, db) -> Result<String>: load uncategorized; if none, return "nothing to categorize"; buildLlms::from_configwith the current taste prompt (ascmd_profile_rebuilddoes) and takebulk(fall back toeditor_or_bulk); onecomplete_jsonat temperature 0.2 with:Validate: interest must be one of the batch (case-insensitive), category trimmed, 1–60 chars; unknown names are ignored; anything unassigned staysTASK: file each new standing interest under one of the reader's interest categories. Existing categories (reuse these names verbatim): <list> Create a new category only when none of the existing ones fits; a new category must be broad enough to hold several interests and named like the existing ones (two to five words, sentence case). Every interest gets exactly one category. New interests: <one per line> Return JSON exactly: {"assignments":[{"interest":"…","category":"…"}]}NULL. Log "categorized N (M new categories: …)". Return that as the job message.pipeline::build_llms: after the clients exist, ifinterests::uncategorized(db)is non-empty and a client is available, runcategorize(warning on error), then rebuild the prompt and the clients exactly as the weekly rebuild path does (the grouping changed). Both the categorizer and the weekly rebuild share that "prompt changed, remake clients" tail.jobs::Job::InterestsCategorize(interests-categorize, description "File uncategorized interests under categories with the bulk model, creating new ones only when needed.",takes_lockNone, not dangerous);main::run_jobdispatch; catalogue tests.- CLI
daily-epub interests <import|backfill>inmain.rs:import [--opml PATH] [--profile PATH](defaultsdata/scour-interests.opmlandconfig.profile_path): parse the OPML with the oldparse_interests(moved intointerests.rsas a private helper) and the profile's## Interestslines, insert withaddskipping duplicates, assign each new row's category withthemes::group_into_themesmapped back per name ("Other standing interests" →NULLso the LLM gets a go at them). Print "imported N, skipped M existing, K left for the categorizer".backfill: load everyarticle_embeddingsrow compatible with the configured model/dimension in chunks, load interest vectors frominterest_embeddings(fetching misses through the service if Voyage is enabled), runinterest_matchesover the whole set (z over the whole cached set stands in for the per-day cohort; say so in the docstring), andINSERT OR IGNOREthe rows withrun_id NULLso real-run rows win. Print the row count. Takes the run lock (it writes what the run writes).
- Tests: the categorizer with
MockBackendassigns two interests, creates one new category, ignores a name not in the batch;importis idempotent;backfillon a two-article fixture writes the expected rows and leaves an existing run-written row alone.
-
Docs (~40). README: the "Web site and dashboard" paragraph gets a sentence on Interests; the Commands section gets
interests import/interests backfill; the configuration table gains the three keys and losesinterests_opml; the Layout section listssrc/interests.rs.docs/ideas.md: strike the "what interests most match" item.
Expect roughly 2 100 lines including tests.
8. Verification
cargo test,cargo clippy,cargo fmtgreen.- Dev seed (
dev/):interests import --opml data/scour-interests.opml, theninterests backfill(no embeddings in the seed → "0 rows", exits 0), open/dashboard/interests: 230 rows, every one categorized except a handful under Uncategorized, weight—everywhere; add "Rust macros" → appears uncategorized; Categorize now with jobs disabled shows the manual command. - After deploy, on the server: run the two commands (§10), open the page, click Categorize now, watch the job page, refresh: no Uncategorized left, new categories (if any) look sane. Rate one article from the issue page; its matched interests' weights move on the next refresh with the expected magnitude (
0.5 → 0.67for a full-strength loved). - Next morning's run:
explainon a selected article showsaffinitypresent with a weight, or absent with the gate at 0 if fewer than 15 ratings credited an interest (expected at first; the log line says which). The Articles page filtered by an interest the reader knows well returns articles that obviously belong, sorted by cosine; nothing unrelated appears at the bottom of the first page. Dashboard timing for that page stays single-digit ms (Server-Timingheader). - The system prompt on the profile page groups Standing interests by the DB categories with the same members as before the change (byte-stable apart from category names that the LLM added).
9. Deferred, with triggers
- Prompt annotation of Standing interests with the strongest and weakest weights (e.g.
Rust ↑,Coffee ↓) — once the affinity gate is open and the weights look right for a couple of weeks. - Renaming an interest in place, and merging duplicates — when the operator actually needs it (delete + add loses nothing but the match rows, which the next runs rebuild).
- Match-row retention — if
article_interestsever exceeds a few million rows; today it grows ~1 k/day. - Implicit ratings feeding the weights — already designed for in the curation plan §6.4; nothing here blocks it.
- Scour — the Scour feeds in Miniflux keep working as sources; nothing here syncs interests back to Scour.
10. Rollout notes
- Deploy the binary. Before restarting the service, delete
interests_opmlfrom/etc/daily-epub/config.toml(the key is gone andConfigrejects unknown keys). Migrations run on the first command. daily-epub interests import --opml /var/lib/daily-epub/data/scour-interests.opml(the profile's## Interestssection is read fromprofile_path; it has been empty so far). Then delete that section fromprofile.md, or leave it — it is ignored either way.daily-epub features backfill --rated-onlyif any rated article lacks an embedding (it prints the count), thendaily-epub interests backfill.- Open
/dashboard/interests, press Categorize now for the leftovers, and read the weights: they are live from that moment because they are derived from the existing ratings. - Optional:
daily-epub generate --dry-runto seeaffinityin a run before the morning.
11. Non-goals (do not add)
- A category editor page, category colours, or ordering beyond alphabetical.
- Per-interest notes, pinning, or manual weight overrides — the weight is derived from ratings only, so the operator changes it by rating.
- Interest suggestions from articles ("you seem to like X") — a separate feature with its own plan if wanted.
- Syncing interests to or from Scour, or OPML export.
- A stored weight column, incremental counters, or a scheduled recompute.
- Configurable
MATCH_MIN_Zor match strength curve — constants until there is a reason. - Public (anonymous or reader-role) links from the Matches line; the dashboard is admin-only.