diff --git a/src/epub/chapters.rs b/src/epub/chapters.rs index e82a4c8..7bfcb1a 100644 --- a/src/epub/chapters.rs +++ b/src/epub/chapters.rs @@ -232,7 +232,24 @@ fn facet_label(token: &str) -> Option { pub struct Understanding { pub kicker: Option, pub topics: Option, - pub interests: Option, + pub interests: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterestRef { + pub name: String, + pub href: String, +} + +impl Understanding { + /// EPUBs keep interest names as plain text, without dashboard links. + pub fn interests_line(&self) -> String { + self.interests + .iter() + .map(|interest| interest.name.as_str()) + .collect::>() + .join(" \u{00b7} ") + } } /// Reader-facing assessment details, split so templates can give each part the @@ -257,7 +274,14 @@ pub fn understanding(pick: &Pick) -> Understanding { }); (kicker, topics) }); - let interests = (!pick.top_interests.is_empty()).then(|| pick.top_interests.join(" \u{00b7} ")); + let interests = pick + .top_interests + .iter() + .map(|name| InterestRef { + name: name.clone(), + href: crate::interests::articles_href(name), + }) + .collect(); Understanding { kicker, @@ -811,7 +835,16 @@ mod tests { Understanding { kicker: Some("Software engineering · Analysis".into()), topics: Some("copy-on-write · ZFS".into()), - interests: Some("Filesystems · Rust".into()), + interests: vec![ + InterestRef { + name: "Filesystems".into(), + href: "/dashboard/articles?interest=Filesystems".into(), + }, + InterestRef { + name: "Rust".into(), + href: "/dashboard/articles?interest=Rust".into(), + }, + ], } ); } @@ -833,7 +866,10 @@ mod tests { assert_eq!( understanding(&pick), Understanding { - interests: Some("Rust".into()), + interests: vec![InterestRef { + name: "Rust".into(), + href: "/dashboard/articles?interest=Rust".into(), + }], ..Understanding::default() } ); diff --git a/src/epub/templates/chapter.xhtml b/src/epub/templates/chapter.xhtml index 55798d6..89b4169 100644 --- a/src/epub/templates/chapter.xhtml +++ b/src/epub/templates/chapter.xhtml @@ -10,13 +10,13 @@ {% if understanding.kicker.is_some() || understanding.topics.is_some() %}

{% if let Some(kicker) = understanding.kicker %}{{ kicker }}{% if let Some(topics) = understanding.topics %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %} -{% if why.is_some() || understanding.interests.is_some() %} +{% if why.is_some() || !understanding.interests.is_empty() %}
{% if let Some(text) = why %}

Why it's here: {{ text }}

{% endif %} -{% if let Some(interests) = understanding.interests %} -

Matches: {{ interests }}

+{% if !understanding.interests.is_empty() %} +

Matches: {{ understanding.interests_line() }}

{% endif %}
{% endif %} diff --git a/src/epub/templates/in_this_issue.xhtml b/src/epub/templates/in_this_issue.xhtml index 4e8a39b..37fdfa7 100644 --- a/src/epub/templates/in_this_issue.xhtml +++ b/src/epub/templates/in_this_issue.xhtml @@ -13,13 +13,13 @@ {% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %}

{% if let Some(kicker) = entry.understanding.kicker %}{{ kicker }}{% if let Some(topics) = entry.understanding.topics %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %} -{% if entry.why.is_some() || entry.understanding.interests.is_some() %} +{% if entry.why.is_some() || !entry.understanding.interests.is_empty() %}
{% if let Some(text) = entry.why %}

Why it's here: {{ text }}

{% endif %} -{% if let Some(interests) = entry.understanding.interests %} -

Matches: {{ interests }}

+{% if !entry.understanding.interests.is_empty() %} +

Matches: {{ entry.understanding.interests_line() }}

{% endif %}
{% endif %} diff --git a/src/interests.rs b/src/interests.rs index aae23c2..d371c9e 100644 --- a/src/interests.rs +++ b/src/interests.rs @@ -64,6 +64,14 @@ pub struct Rates { const INTEREST_COLUMNS: &str = "id, name, category, created_at, categorized_at"; +/// Dashboard article search for one interest name. +pub fn articles_href(name: &str) -> String { + format!( + "/dashboard/articles?interest={}", + crate::web::encode_component(name) + ) +} + /// Parse an OPML export for the one-time interests importer. pub fn parse_opml(raw: &str) -> Vec { let mut seen = BTreeSet::new(); diff --git a/src/web/dashboard/articles.rs b/src/web/dashboard/articles.rs index 64fa54c..6920586 100644 --- a/src/web/dashboard/articles.rs +++ b/src/web/dashboard/articles.rs @@ -53,6 +53,7 @@ pub fn routes() -> Router { #[derive(Debug, Default, Deserialize)] pub struct ArticlesQuery { pub q: Option, + pub interest: Option, pub feed: Option, pub stage: Option, pub reason: Option, @@ -68,8 +69,9 @@ pub struct ArticlesQuery { const RATED: [&str; 7] = ["any", "loved", "good", "down", "slop", "cleared", "none"]; const PUBLISHED: [&str; 2] = ["yes", "no"]; -const ARTICLE_SORTS: [(&str, &str); 7] = [ +const ARTICLE_SORTS: [(&str, &str); 8] = [ ("first_seen", "x.first_seen DESC, x.id DESC"), + ("match", "x.match_cos DESC, x.id DESC"), ("utility", "x.utility DESC, x.id DESC"), ("quality", "x.quality DESC, x.id DESC"), ("fit", "x.fit DESC, x.id DESC"), @@ -82,6 +84,7 @@ const ARTICLE_SORTS: [(&str, &str); 7] = [ #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ArticleFilters { pub q: Option, + pub interest: Option, pub feed: Option, pub stage: Option, pub reason: Option, @@ -93,8 +96,14 @@ pub struct ArticleFilters { pub sort: &'static str, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterestFilter { + pub id: Option, + pub name: String, +} + impl ArticleFilters { - pub fn from_query(query: &ArticlesQuery) -> Self { + pub fn from_query(query: &ArticlesQuery, interest: Option) -> Self { let owned = |value: Option<&str>| value.map(str::to_string); let date = |value: Option<&str>| { non_empty(value) @@ -103,8 +112,19 @@ impl ArticleFilters { }; let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec(); kinds.push(PROVIDER_REJECTED); + let sort = ARTICLE_SORTS + .iter() + .find(|(name, _)| Some(*name) == query.sort.as_deref()) + .map(|(name, _)| *name) + .filter(|sort| *sort != "match" || interest.is_some()) + .unwrap_or(if interest.is_some() { + "match" + } else { + ARTICLE_SORTS[0].0 + }); Self { q: owned(non_empty(query.q.as_deref())), + interest, feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::().ok()), stage: owned(allow_listed(query.stage.as_deref(), &STAGES)), reason: owned(allow_listed(query.reason.as_deref(), &REASONS)), @@ -113,11 +133,7 @@ impl ArticleFilters { from: date(query.from.as_deref()), to: date(query.to.as_deref()), kind: owned(allow_listed(query.kind.as_deref(), &kinds)), - sort: ARTICLE_SORTS - .iter() - .find(|(name, _)| Some(*name) == query.sort.as_deref()) - .map(|(name, _)| *name) - .unwrap_or(ARTICLE_SORTS[0].0), + sort, } } @@ -182,6 +198,10 @@ impl ArticleFilters { fn params(&self) -> Vec<(&'static str, Option)> { vec![ ("q", self.q.clone()), + ( + "interest", + self.interest.as_ref().map(|interest| interest.name.clone()), + ), ("feed", self.feed.map(|feed| feed.to_string())), ("stage", self.stage.clone()), ("reason", self.reason.clone()), @@ -219,6 +239,13 @@ pub struct ArticleListRow { pub rating: Option, pub rating_class: &'static str, pub published: Option, + pub interests: Vec, +} + +#[derive(Debug, Clone)] +pub struct ArticleInterest { + pub name: String, + pub href: String, } const ARTICLE_INNER: &str = @@ -226,12 +253,14 @@ const ARTICLE_INNER: &str = a.word_count, e.feed_id, COALESCE(e.feed_title, '') AS feed_title, l.run_id, l.stage, l.excluded_reason, l.utility, r.date AS run_date, t.score AS triage, t.kind AS triage_kind, d.score AS quality, d.fit AS fit, + {match_cos} AS match_cos, (SELECT re.label FROM rating_events re WHERE re.article_id = a.id AND re.kind = 'explicit' ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating, (SELECT ia.issue_date FROM issue_articles ia WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published FROM articles a + {interest_join} LEFT JOIN entries e ON e.id = a.best_entry_id LEFT JOIN candidate_runs l ON l.article_id = a.id AND l.run_id = (SELECT MAX(c2.run_id) FROM candidate_runs c2 @@ -245,10 +274,16 @@ pub async fn list_articles( config: &Config, filters: &ArticleFilters, page: u32, -) -> Result<(Vec, Pagination), sqlx::Error> { +) -> anyhow::Result<(Vec, Pagination)> { let (clauses, binds) = filters.where_clauses(); - let count_sql = format!("SELECT COUNT(*) FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses}"); - let total: i64 = bind_all(dynamic_query(count_sql), &binds) + let (inner, interest_binds) = article_inner(filters); + let all_binds = interest_binds + .iter() + .cloned() + .chain(binds.iter().cloned()) + .collect::>(); + let count_sql = format!("SELECT COUNT(*) FROM ({inner}) x WHERE 1 = 1{clauses}"); + let total: i64 = bind_all(dynamic_query(count_sql), &all_binds) .fetch_one(db.pool()) .await? .get(0); @@ -258,15 +293,15 @@ pub async fn list_articles( total, }; let select_sql = format!( - "SELECT * FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?", + "SELECT * FROM ({inner}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?", filters.order_by() ); - let rows = bind_all(dynamic_query(select_sql), &binds) + let rows = bind_all(dynamic_query(select_sql), &all_binds) .bind(i64::from(ARTICLES_PER_PAGE)) .bind(pagination.offset()) .fetch_all(db.pool()) .await?; - let rows = rows + let mut rows: Vec = rows .iter() .map(|row| { let id: ArticleId = row.get("id"); @@ -290,12 +325,45 @@ pub async fn list_articles( rating_class: widget_label(rating.as_deref()), rating, published: row.get("published"), + interests: Vec::new(), } }) .collect(); + let ids = rows.iter().map(|row| row.id).collect::>(); + let mut by_article: HashMap> = HashMap::new(); + for matched in crate::interests::matches_for_articles(db, &ids).await? { + by_article + .entry(matched.article_id) + .or_default() + .push(ArticleInterest { + href: crate::interests::articles_href(&matched.name), + name: matched.name, + }); + } + for row in &mut rows { + row.interests = by_article.remove(&row.id).unwrap_or_default(); + } Ok((rows, pagination)) } +fn article_inner(filters: &ArticleFilters) -> (String, Vec) { + match &filters.interest { + Some(interest) => ( + ARTICLE_INNER.replace("{match_cos}", "ai.cos").replace( + "{interest_join}", + "JOIN article_interests ai ON ai.article_id = a.id AND ai.interest_id = ?", + ), + vec![Bind::Int(interest.id.unwrap_or(-1))], + ), + None => ( + ARTICLE_INNER + .replace("{match_cos}", "NULL") + .replace("{interest_join}", ""), + Vec::new(), + ), + } +} + #[derive(Template)] #[template(path = "dashboard/articles.html")] struct ArticlesTemplate { @@ -308,6 +376,7 @@ struct ArticlesTemplate { rated: Vec<&'static str>, kinds: Vec<&'static str>, sorts: Vec<&'static str>, + interest_options: Vec, pager: Pager, } @@ -319,11 +388,24 @@ async fn list( ) -> Result { let viewer = auth.user().await.map(Viewer::from); let config = state.config(); - let filters = ArticleFilters::from_query(&query); + let interest_options = crate::interests::list(&state.db) + .await + .map_err(WebError::Internal)?; + let requested_interest = non_empty(query.interest.as_deref()).map(str::to_string); + let interest = requested_interest.map(|name| { + let found = interest_options + .iter() + .find(|interest| interest.name.eq_ignore_ascii_case(&name)); + InterestFilter { + id: found.map(|interest| interest.id), + name: found.map(|interest| interest.name.clone()).unwrap_or(name), + } + }); + let filters = ArticleFilters::from_query(&query, interest); let page_no = page_number(query.page); let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no) .await - .map_err(db_err)?; + .map_err(WebError::Internal)?; let pager = Pager::new(pagination, "/dashboard/articles", &filters.params()); let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec(); kinds.push(PROVIDER_REJECTED); @@ -342,6 +424,7 @@ async fn list( rated: RATED.to_vec(), kinds, sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(), + interest_options, pager, }) .into_response()) @@ -845,7 +928,7 @@ mod tests { fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters { let mut query = ArticlesQuery::default(); f(&mut query); - ArticleFilters::from_query(&query) + ArticleFilters::from_query(&query, None) } #[tokio::test] @@ -939,6 +1022,86 @@ mod tests { assert_eq!(rows[0].id, 1); } + #[test] + fn the_match_sort_needs_an_interest_and_round_trips_in_the_pager_links() { + let without = query(|q| q.sort = Some("match".into())); + assert_eq!(without.sort, "first_seen"); + assert!(without.params().contains(&("sort", None))); + + let with = ArticlesQuery { + interest: Some("Rust".into()), + ..ArticlesQuery::default() + }; + let with = ArticleFilters::from_query( + &with, + Some(InterestFilter { + id: Some(7), + name: "Rust".into(), + }), + ); + assert_eq!(with.sort, "match", "the interest filter sorts by cosine"); + let params = with.params(); + assert!(params.contains(&("interest", Some("Rust".into())))); + assert!(params.contains(&("sort", Some("match".into())))); + } + + #[tokio::test] + async fn interest_filter_finds_matches_unknown_is_empty_and_badges_link() { + let seed = seed().await; + let crate::interests::AddOutcome::Added(interest_id) = crate::interests::add( + &seed.db, + "Rust & Systems", + Some("Software"), + "2026-09-12T12:00:00Z".parse().unwrap(), + ) + .await + .unwrap() else { + unreachable!(); + }; + sqlx::query( + "INSERT INTO article_interests (article_id, interest_id, cos, z, run_id) + VALUES (1, ?, 0.82, 2.1, ?)", + ) + .bind(interest_id) + .bind(seed.run_id) + .execute(seed.db.pool()) + .await + .unwrap(); + + let app = app_with_users(&seed.db).await; + let admin = login_cookie(&app, "admin", "correct horse battery").await; + let filtered = get( + &app, + "/dashboard/articles?interest=rust+%26+systems", + Some(&admin), + ) + .await; + assert_eq!(filtered.status(), axum::http::StatusCode::OK); + let filtered = crate::web::dashboard::tests::response_text(filtered).await; + assert!(filtered.contains("Article 1 about prose"), "{filtered}"); + assert!(!filtered.contains("Article 2 about graphs"), "{filtered}"); + assert!(filtered.contains("value=\"match\" selected"), "{filtered}"); + assert!( + filtered.contains( + "href=\"/dashboard/articles?interest=Rust+%26+Systems\">Rust & Systems" + ), + "{filtered}" + ); + + let unknown = get( + &app, + "/dashboard/articles?interest=not-a-real-interest", + Some(&admin), + ) + .await; + assert_eq!(unknown.status(), axum::http::StatusCode::OK); + let unknown = crate::web::dashboard::tests::response_text(unknown).await; + assert!( + unknown.contains("No articles match this filter."), + "{unknown}" + ); + } + #[tokio::test] async fn article_detail_shows_assessments_run_history_and_rating_events() { let seed = seed().await; diff --git a/src/web/dashboard/feeds.rs b/src/web/dashboard/feeds.rs index 25d2634..d76ad41 100644 --- a/src/web/dashboard/feeds.rs +++ b/src/web/dashboard/feeds.rs @@ -69,6 +69,12 @@ struct WhyArticle { title: String, } +#[derive(Debug)] +struct WhyInterest { + name: String, + href: String, +} + /// One table row. #[derive(Debug)] struct FeedRow { @@ -79,7 +85,7 @@ struct FeedRow { /// The candidate's title, or its feed URL when it has none. label: String, host: String, - interests: Vec, + interests: Vec, articles: Vec, article_count: usize, first_seen: String, @@ -383,7 +389,13 @@ fn row( ) -> FeedRow { let mut scored = evidence_of(candidate, evidence); let score = discovery::score(&scored); - let interests = discovery::why(&scored); + let interests = discovery::why(&scored) + .into_iter() + .map(|name| WhyInterest { + href: crate::interests::articles_href(&name), + name, + }) + .collect(); // Best evidence first; the articles nothing is known about come last, in // title order. diff --git a/src/web/dashboard/interests.rs b/src/web/dashboard/interests.rs new file mode 100644 index 0000000..a4007be --- /dev/null +++ b/src/web/dashboard/interests.rs @@ -0,0 +1,485 @@ +//! Dashboard: standing interests and their rating-derived weights. + +use std::cmp::Ordering; +use std::collections::BTreeSet; + +use askama::Template; +use axum::Router; +use axum::extract::{Extension, Form, Path, Query, State}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use axum_login::tower_sessions::Session; +use jiff::Timestamp; +use serde::Deserialize; + +use super::jobs::set_flash; +use crate::curate::signals; +use crate::interests as interest_store; +use crate::server::AppState; +use crate::types::ArticleId; +use crate::web::session::{AuthSession, Viewer}; +use crate::web::{Html, Page, WebError, encode_component, take_flash}; + +const PATH: &str = "/dashboard/interests"; +const SORTS: [&str; 4] = ["weight", "name", "matches", "added"]; + +/// Routes contributed by the interests page. +pub fn routes() -> Router { + Router::new() + .route(PATH, get(index).post(add)) + .route("/dashboard/interests/{id}/category", post(set_category)) + .route("/dashboard/interests/{id}/delete", post(delete)) +} + +#[derive(Debug, Default, Deserialize)] +struct InterestsQuery { + category: Option, + sort: Option, +} + +#[derive(Debug, Deserialize)] +struct AddForm { + name: String, + #[serde(default)] + category: String, +} + +#[derive(Debug, Deserialize)] +struct CategoryForm { + #[serde(default)] + category: String, +} + +#[derive(Debug)] +struct CategoryOption { + name: String, +} + +#[derive(Debug)] +struct InterestRow { + id: i64, + name: String, + href: String, + category: Option, + category_href: String, + weight: String, + weight_value: f64, + up: String, + down: String, + rated_matches: usize, + matched_articles: i64, + added: String, + created_at: String, +} + +#[derive(Template)] +#[template(path = "dashboard/interests.html")] +struct InterestsTemplate { + page: Page, + rows: Vec, + categories: Vec, + selected_category: String, + selected_sort: String, + sorts: Vec<&'static str>, + total: usize, + uncategorized: usize, + lookback_days: i64, + half_life_days: String, + affinity_gate: String, + attributable: usize, + affinity_full: usize, + jobs_enabled: bool, +} + +async fn index( + State(state): State, + auth: AuthSession, + Extension(session): Extension, + Query(query): Query, +) -> Result { + let viewer = auth + .user() + .await + .map(Viewer::from) + .ok_or_else(|| WebError::Unauthenticated { next: PATH.into() })?; + let config = state.config(); + let ranking = &config.curation.ranking; + let db = &state.db; + let now = Timestamp::now(); + let interests = interest_store::list(db).await.map_err(WebError::Internal)?; + let total = interests.len(); + let uncategorized = interests + .iter() + .filter(|interest| interest.category.is_none()) + .count(); + + let mut category_names = interests + .iter() + .filter_map(|interest| interest.category.clone()) + .collect::>() + .into_iter() + .collect::>(); + category_names.sort_by(|left, right| { + left.to_lowercase() + .cmp(&right.to_lowercase()) + .then_with(|| left.cmp(right)) + }); + let categories = category_names + .iter() + .map(|name| CategoryOption { name: name.clone() }) + .collect::>(); + + let selected_category = query + .category + .as_deref() + .map(str::trim) + .filter(|category| !category.is_empty()) + .map(str::to_string) + .unwrap_or_default(); + let selected_sort = query + .sort + .as_deref() + .filter(|sort| SORTS.contains(sort)) + .unwrap_or("weight") + .to_string(); + + let ratings = db.current_ratings(ranking.rating_lookback_days).await?; + let rated_ids = ratings + .iter() + .map(|rating| rating.article_id) + .collect::>(); + let matches = interest_store::matches_for_articles(db, &rated_ids) + .await + .map_err(WebError::Internal)?; + let rated = ratings + .iter() + .map(|rating| { + let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0; + ( + rating.article_id, + rating.value, + signals::decay(age_days, ranking.rating_half_life_days), + ) + }) + .collect::>(); + let matched = matches + .iter() + .map(|row| (row.article_id, row.interest_id, row.z)) + .collect::>(); + let rates = interest_store::rates(&rated, &matched); + let counts = interest_store::match_counts(db) + .await + .map_err(WebError::Internal)?; + + let mut rows = interests + .into_iter() + .filter(|interest| match selected_category.as_str() { + "" => true, + "uncategorized" => interest.category.is_none(), + category => interest + .category + .as_deref() + .is_some_and(|current| current.eq_ignore_ascii_case(category)), + }) + .map(|interest| { + let rate = rates + .by_interest + .get(&interest.id) + .copied() + .unwrap_or_default(); + let category_href = interest + .category + .as_deref() + .map(|category| format!("{PATH}?category={}", encode_component(category))) + .unwrap_or_else(|| format!("{PATH}?category=uncategorized")); + InterestRow { + id: interest.id, + href: interest_store::articles_href(&interest.name), + name: interest.name, + category: interest.category, + category_href, + weight: if rate.n > 0 { + format!("{:.2}", rate.weight()) + } else { + "—".into() + }, + weight_value: rate.weight(), + up: format!("{:.2}", rate.up), + down: format!("{:.2}", rate.down), + rated_matches: rate.n, + matched_articles: counts.get(&interest.id).copied().unwrap_or(0), + added: super::fmt_stored_time(Some(&interest.created_at), &config), + created_at: interest.created_at, + } + }) + .collect::>(); + sort_rows(&mut rows, &selected_sort); + + let mut page = Page::new("Interests", Some(viewer), "interests"); + page.flash = take_flash(&session).await?; + Ok(Html(InterestsTemplate { + page, + rows, + categories, + selected_category, + selected_sort, + sorts: SORTS.to_vec(), + total, + uncategorized, + lookback_days: ranking.rating_lookback_days, + half_life_days: format!("{}", ranking.rating_half_life_days), + affinity_gate: format!( + "{:.2}", + signals::gate( + rates.attributable, + ranking.affinity_floor, + ranking.affinity_full + ) + ), + attributable: rates.attributable, + affinity_full: ranking.affinity_full, + jobs_enabled: config.server.jobs_enabled, + }) + .into_response()) +} + +fn sort_rows(rows: &mut [InterestRow], sort: &str) { + rows.sort_by(|left, right| { + let selected = match sort { + "name" => Ordering::Equal, + "matches" => right.matched_articles.cmp(&left.matched_articles), + "added" => right.created_at.cmp(&left.created_at), + _ => right + .weight_value + .partial_cmp(&left.weight_value) + .unwrap_or(Ordering::Equal), + }; + selected.then_with(|| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.name.cmp(&right.name)) + }) + }); +} + +fn form_category(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +async fn add( + State(state): State, + Extension(session): Extension, + Form(form): Form, +) -> Result { + match interest_store::add( + &state.db, + &form.name, + form_category(&form.category), + Timestamp::now(), + ) + .await + { + Ok(interest_store::AddOutcome::Added(_)) => { + set_flash(&session, "success", format!("Added {}.", form.name.trim())).await?; + } + Ok(interest_store::AddOutcome::Duplicate) => { + set_flash( + &session, + "error", + format!("{} already exists.", form.name.trim()), + ) + .await?; + } + Err(error) => set_flash(&session, "error", error.to_string()).await?, + } + Ok(Redirect::to(PATH).into_response()) +} + +async fn set_category( + State(state): State, + Extension(session): Extension, + Path(id): Path, + Form(form): Form, +) -> Result { + interest_store::set_category( + &state.db, + id, + form_category(&form.category), + Timestamp::now(), + ) + .await + .map_err(WebError::Internal)?; + set_flash(&session, "success", "Category saved.".into()).await?; + Ok(Redirect::to(PATH).into_response()) +} + +async fn delete( + State(state): State, + Extension(session): Extension, + Path(id): Path, +) -> Result { + let name: Option = sqlx::query_scalar("SELECT name FROM interests WHERE id = ?") + .bind(id) + .fetch_optional(state.db.pool()) + .await + .map_err(super::db_err)?; + interest_store::delete(&state.db, id) + .await + .map_err(WebError::Internal)?; + set_flash( + &session, + "success", + name.map(|name| format!("Deleted {name}.")) + .unwrap_or_else(|| "Interest already deleted.".into()), + ) + .await?; + Ok(Redirect::to(PATH).into_response()) +} + +#[cfg(test)] +mod tests { + use axum::body::Body; + use axum::http::{Method, Request, StatusCode, header}; + use tower::ServiceExt; + + use super::*; + use crate::interests::AddOutcome; + use crate::web::dashboard::tests::{ + app_with_users, assert_admin_only, login_cookie, response_text, seed, + }; + + async fn post_form( + app: &axum::Router, + uri: &str, + cookie: Option<&str>, + body: &str, + ) -> Response { + let mut request = Request::builder() + .method(Method::POST) + .uri(uri) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header("sec-fetch-site", "same-origin"); + if let Some(cookie) = cookie { + request = request.header(header::COOKIE, cookie); + } + app.clone() + .oneshot(request.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn page_sorts_rating_weights_descending_and_is_admin_only() { + let seed = seed().await; + let now = Timestamp::now(); + let AddOutcome::Added(rust) = interest_store::add(&seed.db, "Rust", Some("Software"), now) + .await + .unwrap() + else { + unreachable!(); + }; + interest_store::add(&seed.db, "Cooking", None, now) + .await + .unwrap(); + sqlx::query( + "INSERT INTO article_interests (article_id, interest_id, cos, z, run_id) + VALUES (1, ?, 0.8, 3.0, ?)", + ) + .bind(rust) + .bind(seed.run_id) + .execute(seed.db.pool()) + .await + .unwrap(); + + let app = app_with_users(&seed.db).await; + let body = assert_admin_only(&app, PATH).await; + let table = body.split_once("").unwrap().1; + assert!(table.find(">Rust").unwrap() < table.find(">Cooking").unwrap()); + assert!( + table.contains("/dashboard/articles?interest=Rust"), + "{table}" + ); + assert!(body.contains("affinity gate"), "{body}"); + + let reader = login_cookie(&app, "reader", "correct horse battery").await; + for (uri, body) in [ + (PATH.to_string(), "name=New&category="), + ( + format!("/dashboard/interests/{rust}/category"), + "category=Other", + ), + (format!("/dashboard/interests/{rust}/delete"), ""), + ] { + let forbidden = post_form(&app, &uri, Some(&reader), body).await; + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN, "{uri}"); + } + } + + #[tokio::test] + async fn add_reports_a_case_insensitive_duplicate() { + let seed = seed().await; + interest_store::add(&seed.db, "Rust", None, Timestamp::now()) + .await + .unwrap(); + let app = app_with_users(&seed.db).await; + let admin = login_cookie(&app, "admin", "correct horse battery").await; + let response = post_form(&app, PATH, Some(&admin), "name=rust&category=").await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let body = + response_text(crate::web::dashboard::tests::get(&app, PATH, Some(&admin)).await).await; + assert!(body.contains("rust already exists."), "{body}"); + assert_eq!(interest_store::list(&seed.db).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn delete_action_removes_matches_and_the_embedding() { + let seed = seed().await; + let AddOutcome::Added(id) = interest_store::add(&seed.db, "Rust", None, Timestamp::now()) + .await + .unwrap() + else { + unreachable!(); + }; + sqlx::query( + "INSERT INTO article_interests (article_id, interest_id, cos, z) + VALUES (1, ?, 0.8, 2.0)", + ) + .bind(id) + .execute(seed.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO interest_embeddings + (interest, model, dimension, embedding, created_at) + VALUES ('Rust', 'test', 1, X'00000000', '2026-09-12T00:00:00Z')", + ) + .execute(seed.db.pool()) + .await + .unwrap(); + let app = app_with_users(&seed.db).await; + let admin = login_cookie(&app, "admin", "correct horse battery").await; + let response = post_form( + &app, + &format!("/dashboard/interests/{id}/delete"), + Some(&admin), + "", + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let interests: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM interests") + .fetch_one(seed.db.pool()) + .await + .unwrap(); + let matches: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM article_interests") + .fetch_one(seed.db.pool()) + .await + .unwrap(); + let embeddings: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM interest_embeddings") + .fetch_one(seed.db.pool()) + .await + .unwrap(); + assert_eq!((interests, matches, embeddings), (0, 0, 0)); + } +} diff --git a/src/web/dashboard/mod.rs b/src/web/dashboard/mod.rs index 7330bbc..3ba9b1e 100644 --- a/src/web/dashboard/mod.rs +++ b/src/web/dashboard/mod.rs @@ -11,6 +11,7 @@ pub mod articles; pub mod feeds; +pub mod interests; pub mod jobs; pub mod profile; pub mod ratings; @@ -45,6 +46,7 @@ pub fn router() -> Router { .merge(runs::routes()) .merge(articles::routes()) .merge(ratings::routes()) + .merge(interests::routes()) .merge(feeds::routes()) .merge(profile::routes()) .merge(settings::routes()) @@ -277,6 +279,7 @@ pub struct SignalLine { #[derive(Debug, Clone)] pub struct InterestLine { pub name: String, + pub href: String, pub z: String, pub cos: String, } @@ -344,6 +347,7 @@ impl SignalsView { .iter() .map(|interest| InterestLine { name: interest.name.clone(), + href: crate::interests::articles_href(&interest.name), z: format!("{:.2}", interest.z), cos: format!("{:.3}", interest.cos), }) @@ -429,6 +433,8 @@ struct OverviewTemplate { ratings_total: i64, access_requests: i64, feed_candidates: i64, + interests_total: usize, + uncategorized_interests: usize, unrated: Vec, active_jobs: Vec, finished_jobs: Vec, @@ -456,6 +462,14 @@ async fn overview( .await .map_err(db_err)?; let feed_candidates = crate::discovery::count(db, "candidate").await?; + let interests = crate::interests::list(db) + .await + .map_err(WebError::Internal)?; + let interests_total = interests.len(); + let uncategorized_interests = interests + .iter() + .filter(|interest| interest.category.is_none()) + .count(); let unrated = unrated_picks(db).await?; let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?; let sparklines = overview_sparklines(db).await?; @@ -475,6 +489,8 @@ async fn overview( ratings_total, access_requests, feed_candidates, + interests_total, + uncategorized_interests, unrated, active_jobs, finished_jobs, @@ -1226,6 +1242,8 @@ pub(crate) mod tests { "/dashboard/articles/1".to_string(), "/dashboard/ratings".to_string(), "/dashboard/ratings?tab=events".to_string(), + "/dashboard/interests".to_string(), + "/dashboard/interests?category=uncategorized&sort=name".to_string(), "/dashboard/profile".to_string(), "/dashboard/stats?days=14".to_string(), "/dashboard/settings".to_string(), diff --git a/src/web/issue.rs b/src/web/issue.rs index 29736a4..4c617f9 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -2012,6 +2012,8 @@ mod tests { let article_id = source.lineup.picks[0].article.id; let dashboard_href = format!("/dashboard/articles/{article_id}"); + let interest_href = crate::interests::articles_href("Filesystems"); + assert!(!issue.contains(&interest_href)); assert!(!issue.contains(&dashboard_href)); let article = app .clone() @@ -2741,6 +2743,7 @@ mod tests { assert!(admin_issue.contains("Was this a good pick?")); assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\"")); assert!(admin_issue.contains(&dashboard_href)); + assert!(admin_issue.contains(&crate::interests::articles_href("Filesystems"))); let admin_article = app .clone() diff --git a/src/web/mod.rs b/src/web/mod.rs index 2e66682..71cefea 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -322,6 +322,7 @@ impl Page { | "runs" | "articles" | "ratings" + | "interests" | "profile" | "stats" | "jobs" diff --git a/src/web/templates/_signals_table.html b/src/web/templates/_signals_table.html index f42a9e9..e57f99d 100644 --- a/src/web/templates/_signals_table.html +++ b/src/web/templates/_signals_table.html @@ -2,7 +2,7 @@
{% for line in signals.lines %}{% endfor %}
signalrawnormweightpresent
{{ line.name }}{{ line.raw }}{{ line.norm }}{{ line.weight }}{% if line.present %}yes{% else %}absent{% endif %}

Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · exploration{% endif %}{% if signals.auto_include %} · auto-include{% endif %}{% if signals.slop_author %} · slop author{% endif %}

-{% if !signals.top_interests.is_empty() %}

Top interests

    {% for interest in signals.top_interests %}
  • {{ interest.name }} · z {{ interest.z }} · cos {{ interest.cos }}
  • {% endfor %}
{% endif %} +{% if !signals.top_interests.is_empty() %}

Top interests

    {% for interest in signals.top_interests %}
  • {{ interest.name }} · z {{ interest.z }} · cos {{ interest.cos }}
  • {% endfor %}
{% endif %} {% if !signals.neighbours.is_empty() %}

Nearest rated neighbours

    {% for neighbour in signals.neighbours %}
  • {{ neighbour.label }} {{ neighbour.title }} · cos {{ neighbour.cos }}
  • {% endfor %}
{% endif %} {% if !signals.notes.is_empty() %}
    {% for note in signals.notes %}
  • {{ note }}
  • {% endfor %}
{% endif %} {% endif %} diff --git a/src/web/templates/_understanding.html b/src/web/templates/_understanding.html index acc7d3e..d9af543 100644 --- a/src/web/templates/_understanding.html +++ b/src/web/templates/_understanding.html @@ -1 +1 @@ -{% if understanding.kicker.is_some() || understanding.topics.is_some() %}

{% if let Some(kicker) = understanding.kicker %}{{ kicker }}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %}{% if why.is_some() || understanding.interests.is_some() %}
{% if let Some(why) = why %}

Why it's here: {{ why }}

{% endif %}{% if let Some(interests) = understanding.interests %}

Matches: {{ interests }}

{% endif %}
{% endif %} +{% if understanding.kicker.is_some() || understanding.topics.is_some() %}

{% if let Some(kicker) = understanding.kicker %}{{ kicker }}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}

{% endif %}{% if why.is_some() || !understanding.interests.is_empty() %}
{% if let Some(why) = why %}

Why it's here: {{ why }}

{% endif %}{% if !understanding.interests.is_empty() %}

Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}{{ interest.name }}{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}

{% endif %}
{% endif %} diff --git a/src/web/templates/dashboard/article.html b/src/web/templates/dashboard/article.html index e8a8dac..7d1388b 100644 --- a/src/web/templates/dashboard/article.html +++ b/src/web/templates/dashboard/article.html @@ -58,7 +58,7 @@

Neighbours and interests

{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}

The latest run recorded no interests or neighbours for this article.

{% else %}
-

Top interests

{% if signals.top_interests.is_empty() %}

None.

{% else %}
{% for interest in signals.top_interests %}{% endfor %}
interestzcos
{{ interest.name }}{{ interest.z }}{{ interest.cos }}
{% endif %}
+

Top interests

{% if signals.top_interests.is_empty() %}

None.

{% else %}
{% for interest in signals.top_interests %}{% endfor %}
interestzcos
{{ interest.name }}{{ interest.z }}{{ interest.cos }}
{% endif %}

Nearest rated neighbours

{% if signals.neighbours.is_empty() %}

None.

{% else %}
{% for neighbour in signals.neighbours %}{% endfor %}
labelcosarticle
{{ neighbour.label }}{{ neighbour.cos }}{{ neighbour.title }}
{% endif %}
{% endif %}{% else %}

No signals recorded.

{% endif %} diff --git a/src/web/templates/dashboard/articles.html b/src/web/templates/dashboard/articles.html index f1a9a02..72f210d 100644 --- a/src/web/templates/dashboard/articles.html +++ b/src/web/templates/dashboard/articles.html @@ -6,6 +6,7 @@
+ @@ -18,11 +19,12 @@
{% include "dashboard/_pager.html" %} {% if articles.len() > 1 %}{% endif %}
- +{% for article in articles %} + @@ -32,6 +34,6 @@ -{% endfor %}{% if articles.is_empty() %}{% endif %}
first seentitlefeedwordslast stagereasonutilitytriagequalityfitratingpublished
first seentitlefeedinterestswordslast stagereasonutilitytriagequalityfitratingpublished
{{ article.first_seen }} {{ article.title }} {% if let Some(feed_id) = article.feed_id %}{{ article.feed }}{% else %}{{ article.feed }}{% endif %}{% for interest in article.interests %}{{ interest.name }}{% if !loop.last %} {% endif %}{% endfor %} {{ article.words }} {% if let Some(stage) = article.stage %}{{ stage }}{% if let Some(run_id) = article.run_id %} {% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}{% endif %}{% else %}never considered{% endif %} {% if let Some(reason) = article.reason %}{{ reason }}{% endif %}{{ article.fit }} {% if let Some(rating) = article.rating %}{{ rating }}{% endif %} {% if let Some(date) = article.published %}{{ date }}{% endif %}
No articles match this filter.
+{% endfor %}{% if articles.is_empty() %}No articles match this filter.{% endif %} {% include "dashboard/_pager.html" %} {% endblock %} diff --git a/src/web/templates/dashboard/feeds.html b/src/web/templates/dashboard/feeds.html index 905ac01..4255ab1 100644 --- a/src/web/templates/dashboard/feeds.html +++ b/src/web/templates/dashboard/feeds.html @@ -11,7 +11,7 @@ {% for row in rows %} {{ row.score }} {{ row.label }}{{ row.host }} -{% for interest in row.interests %}{{ interest }} {% endfor %}{% for article in row.articles %}{% endfor %} +{% for interest in row.interests %}{{ interest.name }} {% endfor %}{% for article in row.articles %}{% endfor %} {{ row.article_count }} {{ row.seen }} {% if status == "candidate" %}
{% else %}{{ row.decided_at }}{% if let Some(href) = row.miniflux_href %} · in Miniflux{% endif %}{% endif %} diff --git a/src/web/templates/dashboard/interests.html b/src/web/templates/dashboard/interests.html new file mode 100644 index 0000000..806cd86 --- /dev/null +++ b/src/web/templates/dashboard/interests.html @@ -0,0 +1,33 @@ +{% extends "layout.html" %}{% block content %}
+
+

Interests

+

{{ total }} standing interests. Weights use the last {{ lookback_days }} days of ratings with a {{ half_life_days }}-day half-life · affinity gate {{ affinity_gate }} · {{ attributable }} of {{ affinity_full }} attributable ratings.

+
{{ uncategorized }} uncategorized
{% if !jobs_enabled %} Jobs are disabled on this server (server.jobs_enabled = false); run daily-epub job run interests-categorize instead.{% endif %}
+ +

Add an interest

+ + +
+
+ +
+ + +
Reset
+
+{% if rows.len() > 1 %}{% endif %} +
+ +{% for row in rows %} + + + + + + + + + +{% endfor %}{% if rows.is_empty() %}{% endif %} +
interestcategoryweightupdownrated matchesmatched articlesaddedactions
{{ row.name }}{% if let Some(category) = row.category %}{{ category }}{% else %}uncategorized{% endif %}{{ row.weight }}{{ row.up }}{{ row.down }}{{ row.rated_matches }}{{ row.matched_articles }}{{ row.added }}
No interests match this filter.
+
{% endblock %} diff --git a/src/web/templates/dashboard/overview.html b/src/web/templates/dashboard/overview.html index e1e3e1b..0488cbc 100644 --- a/src/web/templates/dashboard/overview.html +++ b/src/web/templates/dashboard/overview.html @@ -11,6 +11,7 @@
Unrated picks{{ unrated.len() }}from the last three issues
Active jobs{{ active_jobs.len() }}all jobs
Feed candidates{{ feed_candidates }}review feeds
+
Interests{{ interests_total }}{{ uncategorized_interests }} uncategorized
Access requests{{ access_requests }}review requests
diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html index 5d629ca..83abf07 100644 --- a/src/web/templates/layout.html +++ b/src/web/templates/layout.html @@ -47,6 +47,7 @@ Runs Articles Ratings + Interests Feeds Profile Stats