From af70da14939591e5fdf1ab3285a30f2da34db0ef Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sun, 13 Sep 2026 05:45:53 +0000 Subject: [PATCH] Reuse an existing category's spelling in the categorizer; document the Interests page (step 9) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc --- README.md | 6 +++++- src/interests.rs | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 655a91d..46498ff 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,10 @@ sees complete issues and article chapters and can download available formats from a single download menu. An `admin` can additionally follow direct dashboard links from issue entries and article chapters, rate articles, and use every `/dashboard/*` page, including -settings and jobs. Personalization is shared across accounts for now. +settings and jobs. `/dashboard/interests` lists the standing interests with +their LLM-assigned category and a rating-derived weight, adds or deletes them, +and links every interest name in the UI to the articles it matched. +Personalization is shared across accounts for now. Visitors can request an account with their preferred username at `/request-access`; admins review open requests on `/dashboard/users`. Approving @@ -250,6 +253,7 @@ Set `server.jobs_enabled = false` to make starts unavailable. | `backfill-social` | Refresh recent social scores. | | `features-prune` | Remove stale embeddings and curation telemetry. | | `import-ratings` | Fetch, embed, and rate URLs queued from the Ratings page. | +| `interests-categorize` | File uncategorized interests under categories with the bulk model. | The Ratings page accepts up to 500 historical article URLs at a time with one verdict and optional note. Imports run in the background and show per-URL diff --git a/src/interests.rs b/src/interests.rs index 0cb37c0..522b828 100644 --- a/src/interests.rs +++ b/src/interests.rs @@ -354,8 +354,8 @@ async fn categorize_with_llm(db: &Db, pending: &[Interest], llm: &LlmClient) -> .collect::>(); let existing_categories = categories .iter() - .map(|category| category.to_lowercase()) - .collect::>(); + .map(|category| (category.to_lowercase(), *category)) + .collect::>(); let mut assigned = BTreeSet::new(); let mut new_categories = BTreeSet::new(); let now = Timestamp::now(); @@ -367,10 +367,16 @@ async fn categorize_with_llm(db: &Db, pending: &[Interest], llm: &LlmClient) -> if !(1..=60).contains(&category.chars().count()) || !assigned.insert(interest.id) { continue; } + // A model that answers "software" for an existing "Software" must not + // split the category. + let category = match existing_categories.get(&category.to_lowercase()) { + Some(existing) => existing, + None => { + new_categories.insert(category.to_string()); + category + } + }; set_category(db, interest.id, Some(category), now).await?; - if !existing_categories.contains(&category.to_lowercase()) { - new_categories.insert(category.to_string()); - } } let mut message = format!( @@ -750,7 +756,7 @@ mod tests { let backend = Arc::new(MockBackend::new()); backend.push( r#"{"assignments":[ - {"interest":"RUST MACROS","category":" Software "}, + {"interest":"RUST MACROS","category":" software "}, {"interest":"Wheel-thrown pottery","category":"Creative crafts"}, {"interest":"Not in the batch","category":"Made up"} ]}"#,