Merge branch 'interests-web' into interests

This commit is contained in:
2026-09-13 05:49:25 +00:00
18 changed files with 797 additions and 34 deletions
+40 -4
View File
@@ -232,7 +232,24 @@ fn facet_label(token: &str) -> Option<String> {
pub struct Understanding { pub struct Understanding {
pub kicker: Option<String>, pub kicker: Option<String>,
pub topics: Option<String>, pub topics: Option<String>,
pub interests: Option<String>, pub interests: Vec<InterestRef>,
}
#[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::<Vec<_>>()
.join(" \u{00b7} ")
}
} }
/// Reader-facing assessment details, split so templates can give each part the /// Reader-facing assessment details, split so templates can give each part the
@@ -257,7 +274,14 @@ pub fn understanding(pick: &Pick) -> Understanding {
}); });
(kicker, topics) (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 { Understanding {
kicker, kicker,
@@ -811,7 +835,16 @@ mod tests {
Understanding { Understanding {
kicker: Some("Software engineering · Analysis".into()), kicker: Some("Software engineering · Analysis".into()),
topics: Some("copy-on-write · ZFS".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!( assert_eq!(
understanding(&pick), understanding(&pick),
Understanding { Understanding {
interests: Some("Rust".into()), interests: vec![InterestRef {
name: "Rust".into(),
href: "/dashboard/articles?interest=Rust".into(),
}],
..Understanding::default() ..Understanding::default()
} }
); );
+3 -3
View File
@@ -10,13 +10,13 @@
{% if understanding.kicker.is_some() || understanding.topics.is_some() %} {% if understanding.kicker.is_some() || understanding.topics.is_some() %}
<p class="rubric">{% if let Some(kicker) = understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p> <p class="rubric">{% if let Some(kicker) = understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
{% endif %} {% endif %}
{% if why.is_some() || understanding.interests.is_some() %} {% if why.is_some() || !understanding.interests.is_empty() %}
<div class="why"> <div class="why">
{% if let Some(text) = why %} {% if let Some(text) = why %}
<p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p> <p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %} {% endif %}
{% if let Some(interests) = understanding.interests %} {% if !understanding.interests.is_empty() %}
<p class="why-matches">Matches: {{ interests }}</p> <p class="why-matches">Matches: {{ understanding.interests_line() }}</p>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
+3 -3
View File
@@ -13,13 +13,13 @@
{% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %} {% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %}
<p class="rubric">{% if let Some(kicker) = entry.understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = entry.understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p> <p class="rubric">{% if let Some(kicker) = entry.understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = entry.understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
{% endif %} {% endif %}
{% if entry.why.is_some() || entry.understanding.interests.is_some() %} {% if entry.why.is_some() || !entry.understanding.interests.is_empty() %}
<div class="index-why"> <div class="index-why">
{% if let Some(text) = entry.why %} {% if let Some(text) = entry.why %}
<p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p> <p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %} {% endif %}
{% if let Some(interests) = entry.understanding.interests %} {% if !entry.understanding.interests.is_empty() %}
<p class="why-matches">Matches: {{ interests }}</p> <p class="why-matches">Matches: {{ entry.understanding.interests_line() }}</p>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
+8
View File
@@ -69,6 +69,14 @@ pub struct Rates {
const INTEREST_COLUMNS: &str = "id, name, category, created_at, categorized_at"; 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. /// Parse an OPML export for the one-time interests importer.
pub fn parse_opml(raw: &str) -> Vec<String> { pub fn parse_opml(raw: &str) -> Vec<String> {
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
+179 -16
View File
@@ -53,6 +53,7 @@ pub fn routes() -> Router<AppState> {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct ArticlesQuery { pub struct ArticlesQuery {
pub q: Option<String>, pub q: Option<String>,
pub interest: Option<String>,
pub feed: Option<String>, pub feed: Option<String>,
pub stage: Option<String>, pub stage: Option<String>,
pub reason: Option<String>, pub reason: Option<String>,
@@ -68,8 +69,9 @@ pub struct ArticlesQuery {
const RATED: [&str; 7] = ["any", "loved", "good", "down", "slop", "cleared", "none"]; const RATED: [&str; 7] = ["any", "loved", "good", "down", "slop", "cleared", "none"];
const PUBLISHED: [&str; 2] = ["yes", "no"]; 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"), ("first_seen", "x.first_seen DESC, x.id DESC"),
("match", "x.match_cos DESC, x.id DESC"),
("utility", "x.utility DESC, x.id DESC"), ("utility", "x.utility DESC, x.id DESC"),
("quality", "x.quality DESC, x.id DESC"), ("quality", "x.quality DESC, x.id DESC"),
("fit", "x.fit 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)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArticleFilters { pub struct ArticleFilters {
pub q: Option<String>, pub q: Option<String>,
pub interest: Option<InterestFilter>,
pub feed: Option<i64>, pub feed: Option<i64>,
pub stage: Option<String>, pub stage: Option<String>,
pub reason: Option<String>, pub reason: Option<String>,
@@ -93,8 +96,14 @@ pub struct ArticleFilters {
pub sort: &'static str, pub sort: &'static str,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterestFilter {
pub id: Option<i64>,
pub name: String,
}
impl ArticleFilters { impl ArticleFilters {
pub fn from_query(query: &ArticlesQuery) -> Self { pub fn from_query(query: &ArticlesQuery, interest: Option<InterestFilter>) -> Self {
let owned = |value: Option<&str>| value.map(str::to_string); let owned = |value: Option<&str>| value.map(str::to_string);
let date = |value: Option<&str>| { let date = |value: Option<&str>| {
non_empty(value) non_empty(value)
@@ -103,8 +112,19 @@ impl ArticleFilters {
}; };
let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec(); let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
kinds.push(PROVIDER_REJECTED); 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 { Self {
q: owned(non_empty(query.q.as_deref())), q: owned(non_empty(query.q.as_deref())),
interest,
feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::<i64>().ok()), feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::<i64>().ok()),
stage: owned(allow_listed(query.stage.as_deref(), &STAGES)), stage: owned(allow_listed(query.stage.as_deref(), &STAGES)),
reason: owned(allow_listed(query.reason.as_deref(), &REASONS)), reason: owned(allow_listed(query.reason.as_deref(), &REASONS)),
@@ -113,11 +133,7 @@ impl ArticleFilters {
from: date(query.from.as_deref()), from: date(query.from.as_deref()),
to: date(query.to.as_deref()), to: date(query.to.as_deref()),
kind: owned(allow_listed(query.kind.as_deref(), &kinds)), kind: owned(allow_listed(query.kind.as_deref(), &kinds)),
sort: ARTICLE_SORTS sort,
.iter()
.find(|(name, _)| Some(*name) == query.sort.as_deref())
.map(|(name, _)| *name)
.unwrap_or(ARTICLE_SORTS[0].0),
} }
} }
@@ -182,6 +198,10 @@ impl ArticleFilters {
fn params(&self) -> Vec<(&'static str, Option<String>)> { fn params(&self) -> Vec<(&'static str, Option<String>)> {
vec![ vec![
("q", self.q.clone()), ("q", self.q.clone()),
(
"interest",
self.interest.as_ref().map(|interest| interest.name.clone()),
),
("feed", self.feed.map(|feed| feed.to_string())), ("feed", self.feed.map(|feed| feed.to_string())),
("stage", self.stage.clone()), ("stage", self.stage.clone()),
("reason", self.reason.clone()), ("reason", self.reason.clone()),
@@ -219,6 +239,13 @@ pub struct ArticleListRow {
pub rating: Option<String>, pub rating: Option<String>,
pub rating_class: &'static str, pub rating_class: &'static str,
pub published: Option<String>, pub published: Option<String>,
pub interests: Vec<ArticleInterest>,
}
#[derive(Debug, Clone)]
pub struct ArticleInterest {
pub name: String,
pub href: String,
} }
const ARTICLE_INNER: &str = 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, 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, 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, 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 (SELECT re.label FROM rating_events re
WHERE re.article_id = a.id AND re.kind = 'explicit' WHERE re.article_id = a.id AND re.kind = 'explicit'
ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating, ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating,
(SELECT ia.issue_date FROM issue_articles ia (SELECT ia.issue_date FROM issue_articles ia
WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published
FROM articles a FROM articles a
{interest_join}
LEFT JOIN entries e ON e.id = a.best_entry_id LEFT JOIN entries e ON e.id = a.best_entry_id
LEFT JOIN candidate_runs l ON l.article_id = a.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 AND l.run_id = (SELECT MAX(c2.run_id) FROM candidate_runs c2
@@ -245,10 +274,16 @@ pub async fn list_articles(
config: &Config, config: &Config,
filters: &ArticleFilters, filters: &ArticleFilters,
page: u32, page: u32,
) -> Result<(Vec<ArticleListRow>, Pagination), sqlx::Error> { ) -> anyhow::Result<(Vec<ArticleListRow>, Pagination)> {
let (clauses, binds) = filters.where_clauses(); let (clauses, binds) = filters.where_clauses();
let count_sql = format!("SELECT COUNT(*) FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses}"); let (inner, interest_binds) = article_inner(filters);
let total: i64 = bind_all(dynamic_query(count_sql), &binds) let all_binds = interest_binds
.iter()
.cloned()
.chain(binds.iter().cloned())
.collect::<Vec<_>>();
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()) .fetch_one(db.pool())
.await? .await?
.get(0); .get(0);
@@ -258,15 +293,15 @@ pub async fn list_articles(
total, total,
}; };
let select_sql = format!( 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() 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(i64::from(ARTICLES_PER_PAGE))
.bind(pagination.offset()) .bind(pagination.offset())
.fetch_all(db.pool()) .fetch_all(db.pool())
.await?; .await?;
let rows = rows let mut rows: Vec<ArticleListRow> = rows
.iter() .iter()
.map(|row| { .map(|row| {
let id: ArticleId = row.get("id"); let id: ArticleId = row.get("id");
@@ -290,12 +325,45 @@ pub async fn list_articles(
rating_class: widget_label(rating.as_deref()), rating_class: widget_label(rating.as_deref()),
rating, rating,
published: row.get("published"), published: row.get("published"),
interests: Vec::new(),
} }
}) })
.collect(); .collect();
let ids = rows.iter().map(|row| row.id).collect::<Vec<_>>();
let mut by_article: HashMap<ArticleId, Vec<ArticleInterest>> = 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)) Ok((rows, pagination))
} }
fn article_inner(filters: &ArticleFilters) -> (String, Vec<Bind>) {
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)] #[derive(Template)]
#[template(path = "dashboard/articles.html")] #[template(path = "dashboard/articles.html")]
struct ArticlesTemplate { struct ArticlesTemplate {
@@ -308,6 +376,7 @@ struct ArticlesTemplate {
rated: Vec<&'static str>, rated: Vec<&'static str>,
kinds: Vec<&'static str>, kinds: Vec<&'static str>,
sorts: Vec<&'static str>, sorts: Vec<&'static str>,
interest_options: Vec<crate::interests::Interest>,
pager: Pager, pager: Pager,
} }
@@ -319,11 +388,24 @@ async fn list(
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let viewer = auth.user().await.map(Viewer::from); let viewer = auth.user().await.map(Viewer::from);
let config = state.config(); 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 page_no = page_number(query.page);
let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no) let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no)
.await .await
.map_err(db_err)?; .map_err(WebError::Internal)?;
let pager = Pager::new(pagination, "/dashboard/articles", &filters.params()); let pager = Pager::new(pagination, "/dashboard/articles", &filters.params());
let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec(); let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec();
kinds.push(PROVIDER_REJECTED); kinds.push(PROVIDER_REJECTED);
@@ -342,6 +424,7 @@ async fn list(
rated: RATED.to_vec(), rated: RATED.to_vec(),
kinds, kinds,
sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(), sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(),
interest_options,
pager, pager,
}) })
.into_response()) .into_response())
@@ -845,7 +928,7 @@ mod tests {
fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters { fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters {
let mut query = ArticlesQuery::default(); let mut query = ArticlesQuery::default();
f(&mut query); f(&mut query);
ArticleFilters::from_query(&query) ArticleFilters::from_query(&query, None)
} }
#[tokio::test] #[tokio::test]
@@ -939,6 +1022,86 @@ mod tests {
assert_eq!(rows[0].id, 1); 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 &#38; Systems</a>"
),
"{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] #[tokio::test]
async fn article_detail_shows_assessments_run_history_and_rating_events() { async fn article_detail_shows_assessments_run_history_and_rating_events() {
let seed = seed().await; let seed = seed().await;
+14 -2
View File
@@ -69,6 +69,12 @@ struct WhyArticle {
title: String, title: String,
} }
#[derive(Debug)]
struct WhyInterest {
name: String,
href: String,
}
/// One table row. /// One table row.
#[derive(Debug)] #[derive(Debug)]
struct FeedRow { struct FeedRow {
@@ -79,7 +85,7 @@ struct FeedRow {
/// The candidate's title, or its feed URL when it has none. /// The candidate's title, or its feed URL when it has none.
label: String, label: String,
host: String, host: String,
interests: Vec<String>, interests: Vec<WhyInterest>,
articles: Vec<WhyArticle>, articles: Vec<WhyArticle>,
article_count: usize, article_count: usize,
first_seen: String, first_seen: String,
@@ -383,7 +389,13 @@ fn row(
) -> FeedRow { ) -> FeedRow {
let mut scored = evidence_of(candidate, evidence); let mut scored = evidence_of(candidate, evidence);
let score = discovery::score(&scored); 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 // Best evidence first; the articles nothing is known about come last, in
// title order. // title order.
+485
View File
@@ -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<AppState> {
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<String>,
sort: Option<String>,
}
#[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<String>,
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<InterestRow>,
categories: Vec<CategoryOption>,
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<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Query(query): Query<InterestsQuery>,
) -> Result<Response, WebError> {
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::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<(ArticleId, f64, f64)>>();
let matched = matches
.iter()
.map(|row| (row.article_id, row.interest_id, row.z))
.collect::<Vec<_>>();
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::<Vec<_>>();
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<AppState>,
Extension(session): Extension<Session>,
Form(form): Form<AddForm>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(session): Extension<Session>,
Path(id): Path<i64>,
Form(form): Form<CategoryForm>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(session): Extension<Session>,
Path(id): Path<i64>,
) -> Result<Response, WebError> {
let name: Option<String> = 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("<tbody>").unwrap().1;
assert!(table.find(">Rust</a>").unwrap() < table.find(">Cooking</a>").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));
}
}
+18
View File
@@ -11,6 +11,7 @@
pub mod articles; pub mod articles;
pub mod feeds; pub mod feeds;
pub mod interests;
pub mod jobs; pub mod jobs;
pub mod profile; pub mod profile;
pub mod ratings; pub mod ratings;
@@ -45,6 +46,7 @@ pub fn router() -> Router<AppState> {
.merge(runs::routes()) .merge(runs::routes())
.merge(articles::routes()) .merge(articles::routes())
.merge(ratings::routes()) .merge(ratings::routes())
.merge(interests::routes())
.merge(feeds::routes()) .merge(feeds::routes())
.merge(profile::routes()) .merge(profile::routes())
.merge(settings::routes()) .merge(settings::routes())
@@ -277,6 +279,7 @@ pub struct SignalLine {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct InterestLine { pub struct InterestLine {
pub name: String, pub name: String,
pub href: String,
pub z: String, pub z: String,
pub cos: String, pub cos: String,
} }
@@ -344,6 +347,7 @@ impl SignalsView {
.iter() .iter()
.map(|interest| InterestLine { .map(|interest| InterestLine {
name: interest.name.clone(), name: interest.name.clone(),
href: crate::interests::articles_href(&interest.name),
z: format!("{:.2}", interest.z), z: format!("{:.2}", interest.z),
cos: format!("{:.3}", interest.cos), cos: format!("{:.3}", interest.cos),
}) })
@@ -429,6 +433,8 @@ struct OverviewTemplate {
ratings_total: i64, ratings_total: i64,
access_requests: i64, access_requests: i64,
feed_candidates: i64, feed_candidates: i64,
interests_total: usize,
uncategorized_interests: usize,
unrated: Vec<UnratedPick>, unrated: Vec<UnratedPick>,
active_jobs: Vec<JobLine>, active_jobs: Vec<JobLine>,
finished_jobs: Vec<JobLine>, finished_jobs: Vec<JobLine>,
@@ -456,6 +462,14 @@ async fn overview(
.await .await
.map_err(db_err)?; .map_err(db_err)?;
let feed_candidates = crate::discovery::count(db, "candidate").await?; 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 unrated = unrated_picks(db).await?;
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?; let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
let sparklines = overview_sparklines(db).await?; let sparklines = overview_sparklines(db).await?;
@@ -475,6 +489,8 @@ async fn overview(
ratings_total, ratings_total,
access_requests, access_requests,
feed_candidates, feed_candidates,
interests_total,
uncategorized_interests,
unrated, unrated,
active_jobs, active_jobs,
finished_jobs, finished_jobs,
@@ -1226,6 +1242,8 @@ pub(crate) mod tests {
"/dashboard/articles/1".to_string(), "/dashboard/articles/1".to_string(),
"/dashboard/ratings".to_string(), "/dashboard/ratings".to_string(),
"/dashboard/ratings?tab=events".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/profile".to_string(),
"/dashboard/stats?days=14".to_string(), "/dashboard/stats?days=14".to_string(),
"/dashboard/settings".to_string(), "/dashboard/settings".to_string(),
+3
View File
@@ -2012,6 +2012,8 @@ mod tests {
let article_id = source.lineup.picks[0].article.id; let article_id = source.lineup.picks[0].article.id;
let dashboard_href = format!("/dashboard/articles/{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)); assert!(!issue.contains(&dashboard_href));
let article = app let article = app
.clone() .clone()
@@ -2741,6 +2743,7 @@ mod tests {
assert!(admin_issue.contains("Was this a good pick?")); assert!(admin_issue.contains("Was this a good pick?"));
assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\"")); assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\""));
assert!(admin_issue.contains(&dashboard_href)); assert!(admin_issue.contains(&dashboard_href));
assert!(admin_issue.contains(&crate::interests::articles_href("Filesystems")));
let admin_article = app let admin_article = app
.clone() .clone()
+1
View File
@@ -322,6 +322,7 @@ impl Page {
| "runs" | "runs"
| "articles" | "articles"
| "ratings" | "ratings"
| "interests"
| "profile" | "profile"
| "stats" | "stats"
| "jobs" | "jobs"
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead> <div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div> <tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}{% if signals.slop_author %} · <span class="badge down">slop author</span>{% endif %}</p> <p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}{% if signals.slop_author %} · <span class="badge down">slop author</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %} {% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li><a href="{{ interest.href }}">{{ interest.name }}</a> <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p class="page-eyebrow">Nearest rated neighbours</p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %} {% if !signals.neighbours.is_empty() %}<p class="page-eyebrow">Nearest rated neighbours</p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %} {% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
</div>{% endif %} </div>{% endif %}
+1 -1
View File
@@ -1 +1 @@
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}<p class="mt-3 font-sans text-sm text-muted">{% if let Some(kicker) = understanding.kicker %}<span class="text-[0.72rem] uppercase tracking-[0.12em]">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}<span class="ml-3">{{ topics }}</span>{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}<span>{{ topics }}</span>{% endif %}{% endif %}</p>{% endif %}{% if why.is_some() || understanding.interests.is_some() %}<div class="mt-4 border-l-2 border-accent pl-3">{% if let Some(why) = why %}<p class="italic text-ink-2">Why it's here: {{ why }}</p>{% endif %}{% if let Some(interests) = understanding.interests %}<p class="mt-1 font-sans text-sm text-muted">Matches: {{ interests }}</p>{% endif %}</div>{% endif %} {% if understanding.kicker.is_some() || understanding.topics.is_some() %}<p class="mt-3 font-sans text-sm text-muted">{% if let Some(kicker) = understanding.kicker %}<span class="text-[0.72rem] uppercase tracking-[0.12em]">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}<span class="ml-3">{{ topics }}</span>{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}<span>{{ topics }}</span>{% endif %}{% endif %}</p>{% endif %}{% if why.is_some() || !understanding.interests.is_empty() %}<div class="mt-4 border-l-2 border-accent pl-3">{% if let Some(why) = why %}<p class="italic text-ink-2">Why it's here: {{ why }}</p>{% endif %}{% if !understanding.interests.is_empty() %}<p class="mt-1 font-sans text-sm text-muted">Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}<a href="{{ interest.href }}">{{ interest.name }}</a>{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}</div>{% endif %}
+1 -1
View File
@@ -58,7 +58,7 @@
<h2>Neighbours and interests</h2> <h2>Neighbours and interests</h2>
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards"> {% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td>{{ interest.name }}</td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section> <section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td><a href="{{ interest.href }}">{{ interest.name }}</a></td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section> <section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %} </div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
+4 -2
View File
@@ -6,6 +6,7 @@
<form class="filters" method="get" action="/dashboard/articles"> <form class="filters" method="get" action="/dashboard/articles">
<label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label> <label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label>
<label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></label> <label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></label>
<label>Interest <select name="interest"><option value="">any</option>{% for interest in interest_options %}<option value="{{ interest.name }}"{% if let Some(current) = filters.interest %}{% if current.name.as_str() == interest.name.as_str() %} selected{% endif %}{% endif %}>{{ interest.name }}</option>{% endfor %}</select></label>
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label> <label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label> <label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Rated <select name="rated"><option value="">all</option>{% for name in rated %}<option value="{{ name }}"{% if let Some(current) = filters.rated %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label> <label>Rated <select name="rated"><option value="">all</option>{% for name in rated %}<option value="{{ name }}"{% if let Some(current) = filters.rated %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
@@ -18,11 +19,12 @@
</form> </form>
{% include "dashboard/_pager.html" %} {% include "dashboard/_pager.html" %}
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="articles" data-filter> {% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="articles" data-filter>
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead> <thead><tr><th>first seen</th><th>title</th><th>feed</th><th>interests</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
<tbody>{% for article in articles %}<tr> <tbody>{% for article in articles %}<tr>
<td class="cell-tight text-muted">{{ article.first_seen }}</td> <td class="cell-tight text-muted">{{ article.first_seen }}</td>
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td> <td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
<td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td> <td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
<td class="cell-wrap">{% for interest in article.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a>{% if !loop.last %} {% endif %}{% endfor %}</td>
<td class="num">{{ article.words }}</td> <td class="num">{{ article.words }}</td>
<td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td> <td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
<td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td> <td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
@@ -32,6 +34,6 @@
<td class="num">{{ article.fit }}</td> <td class="num">{{ article.fit }}</td>
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td> <td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td> <td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="12" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div> </tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="13" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %} {% include "dashboard/_pager.html" %}
</section>{% endblock %} </section>{% endblock %}
+1 -1
View File
@@ -11,7 +11,7 @@
<tbody>{% for row in rows %}<tr> <tbody>{% for row in rows %}<tr>
<td class="num tabular-nums">{{ row.score }}</td> <td class="num tabular-nums">{{ row.score }}</td>
<td class="cell-wrap"><a class="line-clamp-2" href="{{ row.feed_url }}" rel="noopener" target="_blank" title="{{ row.feed_url }}">{{ row.label }}</a><span class="muted block truncate text-xs" title="{{ row.host }}">{{ row.host }}</span></td> <td class="cell-wrap"><a class="line-clamp-2" href="{{ row.feed_url }}" rel="noopener" target="_blank" title="{{ row.feed_url }}">{{ row.label }}</a><span class="muted block truncate text-xs" title="{{ row.host }}">{{ row.host }}</span></td>
<td class="cell-wrap">{% for interest in row.interests %}<span class="badge">{{ interest }}</span> {% endfor %}{% for article in row.articles %}<div class="line-clamp-1 text-xs"><a href="/dashboard/articles/{{ article.id }}" title="{{ article.title }}">{{ article.title }}</a></div>{% endfor %}</td> <td class="cell-wrap">{% for interest in row.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a> {% endfor %}{% for article in row.articles %}<div class="line-clamp-1 text-xs"><a href="/dashboard/articles/{{ article.id }}" title="{{ article.title }}">{{ article.title }}</a></div>{% endfor %}</td>
<td class="num">{{ row.article_count }}</td> <td class="num">{{ row.article_count }}</td>
<td class="cell-tight text-xs text-muted"><span title="First seen {{ row.first_seen }} · last seen {{ row.last_seen }}">{{ row.seen }}</span></td> <td class="cell-tight text-xs text-muted"><span title="First seen {{ row.first_seen }} · last seen {{ row.last_seen }}">{{ row.seen }}</span></td>
<td>{% if status == "candidate" %}<div class="flex flex-wrap items-start gap-2"><form class="form-inline flex-nowrap" method="post" action="/dashboard/feeds/{{ row.id }}/add"><select class="min-w-0 max-w-36 truncate" name="category_id" aria-label="Category for {{ row.label }}"{% if categories.is_empty() %} disabled{% endif %}>{% for category in categories %}<option value="{{ category.id }}"{% if category.id == selected_category %} selected{% endif %}>{{ category.title }}</option>{% endfor %}</select><button class="btn-primary" type="submit"{% if categories.is_empty() %} disabled title="Miniflux categories are unavailable"{% endif %}>Add</button></form><form method="post" action="/dashboard/feeds/{{ row.id }}/dismiss"><button class="btn" type="submit">Dismiss</button></form></div>{% else %}<span class="muted text-xs">{{ row.decided_at }}{% if let Some(href) = row.miniflux_href %} · <a href="{{ href }}" rel="noopener" target="_blank">in Miniflux</a>{% endif %}</span>{% endif %}</td> <td>{% if status == "candidate" %}<div class="flex flex-wrap items-start gap-2"><form class="form-inline flex-nowrap" method="post" action="/dashboard/feeds/{{ row.id }}/add"><select class="min-w-0 max-w-36 truncate" name="category_id" aria-label="Category for {{ row.label }}"{% if categories.is_empty() %} disabled{% endif %}>{% for category in categories %}<option value="{{ category.id }}"{% if category.id == selected_category %} selected{% endif %}>{{ category.title }}</option>{% endfor %}</select><button class="btn-primary" type="submit"{% if categories.is_empty() %} disabled title="Miniflux categories are unavailable"{% endif %}>Add</button></form><form method="post" action="/dashboard/feeds/{{ row.id }}/dismiss"><button class="btn" type="submit">Dismiss</button></form></div>{% else %}<span class="muted text-xs">{{ row.decided_at }}{% if let Some(href) = row.miniflux_href %} · <a href="{{ href }}" rel="noopener" target="_blank">in Miniflux</a>{% endif %}</span>{% endif %}</td>
@@ -0,0 +1,33 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard interests">
<header class="page-head"><div>
<h1>Interests</h1>
<p class="page-desc">{{ 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.</p>
</div><div class="page-actions"><span class="badge">{{ uncategorized }} uncategorized</span><form method="post" action="/dashboard/jobs/interests-categorize" class="form-inline"><button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Categorize now</button>{% if !jobs_enabled %} <span class="meta text-xs">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub job run interests-categorize</code> instead.</span>{% endif %}</form></div></header>
<section class="card"><h2>Add an interest</h2><form class="filters" method="post" action="/dashboard/interests">
<label>Interest <input name="name" type="text" minlength="1" maxlength="80" required placeholder="e.g. Rust macros"></label>
<label>Category <select name="category"><option value="">let the categorizer decide</option>{% for category in categories %}<option value="{{ category.name }}">{{ category.name }}</option>{% endfor %}</select></label>
<div class="filter-actions"><button class="btn btn-primary" type="submit">Add</button></div>
</form></section>
<form class="filters" method="get" action="/dashboard/interests">
<label>Category <select name="category"><option value="">all</option><option value="uncategorized"{% if selected_category == "uncategorized" %} selected{% endif %}>uncategorized</option>{% for category in categories %}<option value="{{ category.name }}"{% if selected_category == category.name.as_str() %} selected{% endif %}>{{ category.name }}</option>{% endfor %}</select></label>
<label>Sort <select name="sort">{% for sort in sorts %}<option value="{{ sort }}"{% if selected_sort == *sort %} selected{% endif %}>{{ sort }}</option>{% endfor %}</select></label>
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/interests">Reset</a></div>
</form>
{% if rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter interests" aria-label="Filter interests" data-table-filter>{% endif %}
<div class="scroll-x tall"><table data-filter>
<thead><tr><th>interest</th><th>category</th><th class="num">weight</th><th class="num">up</th><th class="num">down</th><th class="num">rated matches</th><th class="num">matched articles</th><th>added</th><th>actions</th></tr></thead>
<tbody>{% for row in rows %}<tr>
<td class="cell-wrap"><a href="{{ row.href }}">{{ row.name }}</a></td>
<td class="cell-tight">{% if let Some(category) = row.category %}<a href="{{ row.category_href }}">{{ category }}</a>{% else %}<a class="muted" href="{{ row.category_href }}">uncategorized</a>{% endif %}</td>
<td class="num tabular-nums">{{ row.weight }}</td>
<td class="num tabular-nums">{{ row.up }}</td>
<td class="num tabular-nums">{{ row.down }}</td>
<td class="num tabular-nums">{{ row.rated_matches }}</td>
<td class="num tabular-nums">{{ row.matched_articles }}</td>
<td class="cell-tight text-muted">{{ row.added }}</td>
<td><div class="flex flex-wrap items-start gap-2"><form class="form-inline flex-nowrap" method="post" action="/dashboard/interests/{{ row.id }}/category"><select class="min-w-0 max-w-44 truncate" name="category" aria-label="Category for {{ row.name }}"><option value="">uncategorized</option>{% for category in categories %}<option value="{{ category.name }}"{% if let Some(current) = row.category %}{% if current.as_str() == category.name.as_str() %} selected{% endif %}{% endif %}>{{ category.name }}</option>{% endfor %}</select><button class="btn" type="submit">Save</button></form><form method="post" action="/dashboard/interests/{{ row.id }}/delete" onsubmit="return confirm('Delete this interest? Its article matches and cached embedding will also be removed.')"><button class="btn" type="submit">Delete</button></form></div></td>
</tr>{% endfor %}{% if rows.is_empty() %}<tr><td colspan="9" class="text-muted">No interests match this filter.</td></tr>{% endif %}</tbody>
</table></div>
</section>{% endblock %}
@@ -11,6 +11,7 @@
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div> <div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div> <div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
<div class="tile"><span class="tile-label">Feed candidates</span><span class="tile-num">{{ feed_candidates }}</span><span class="tile-delta"><a href="/dashboard/feeds">review feeds</a></span></div> <div class="tile"><span class="tile-label">Feed candidates</span><span class="tile-num">{{ feed_candidates }}</span><span class="tile-delta"><a href="/dashboard/feeds">review feeds</a></span></div>
<div class="tile"><span class="tile-label">Interests</span><span class="tile-num">{{ interests_total }}</span><span class="tile-delta"><a href="/dashboard/interests?category=uncategorized">{{ uncategorized_interests }} uncategorized</a></span></div>
<div class="tile"><span class="tile-label">Access requests</span><span class="tile-num">{{ access_requests }}</span><span class="tile-delta"><a href="/dashboard/users">review requests</a></span></div> <div class="tile"><span class="tile-label">Access requests</span><span class="tile-num">{{ access_requests }}</span><span class="tile-delta"><a href="/dashboard/users">review requests</a></span></div>
</div> </div>
+1
View File
@@ -47,6 +47,7 @@
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a> <a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a> <a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a> <a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "interests" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/interests"{% if page.active_nav == "interests" %} aria-current="page"{% endif %}>Interests</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "feeds" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/feeds"{% if page.active_nav == "feeds" %} aria-current="page"{% endif %}>Feeds</a> <a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "feeds" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/feeds"{% if page.active_nav == "feeds" %} aria-current="page"{% endif %}>Feeds</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a> <a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a> <a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>