Merge branch 'interests-web' into interests
This commit is contained in:
+40
-4
@@ -232,7 +232,24 @@ fn facet_label(token: &str) -> Option<String> {
|
||||
pub struct Understanding {
|
||||
pub kicker: 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
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
{% 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 %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
|
||||
{% endif %}
|
||||
{% if why.is_some() || understanding.interests.is_some() %}
|
||||
{% if why.is_some() || !understanding.interests.is_empty() %}
|
||||
<div class="why">
|
||||
{% if let Some(text) = why %}
|
||||
<p class="why-line"><em>Why it's here: {{ text }}</em></p>
|
||||
{% endif %}
|
||||
{% if let Some(interests) = understanding.interests %}
|
||||
<p class="why-matches">Matches: {{ interests }}</p>
|
||||
{% if !understanding.interests.is_empty() %}
|
||||
<p class="why-matches">Matches: {{ understanding.interests_line() }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
{% 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 %}   {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
|
||||
{% 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">
|
||||
{% if let Some(text) = entry.why %}
|
||||
<p class="why-line"><em>Why it's here: {{ text }}</em></p>
|
||||
{% endif %}
|
||||
{% if let Some(interests) = entry.understanding.interests %}
|
||||
<p class="why-matches">Matches: {{ interests }}</p>
|
||||
{% if !entry.understanding.interests.is_empty() %}
|
||||
<p class="why-matches">Matches: {{ entry.understanding.interests_line() }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -69,6 +69,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<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
|
||||
+179
-16
@@ -53,6 +53,7 @@ pub fn routes() -> Router<AppState> {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ArticlesQuery {
|
||||
pub q: Option<String>,
|
||||
pub interest: Option<String>,
|
||||
pub feed: Option<String>,
|
||||
pub stage: 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 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<String>,
|
||||
pub interest: Option<InterestFilter>,
|
||||
pub feed: Option<i64>,
|
||||
pub stage: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
@@ -93,8 +96,14 @@ pub struct ArticleFilters {
|
||||
pub sort: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterestFilter {
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
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 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::<i64>().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<String>)> {
|
||||
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<String>,
|
||||
pub rating_class: &'static str,
|
||||
pub published: Option<String>,
|
||||
pub interests: Vec<ArticleInterest>,
|
||||
}
|
||||
|
||||
#[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<ArticleListRow>, Pagination), sqlx::Error> {
|
||||
) -> anyhow::Result<(Vec<ArticleListRow>, 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::<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())
|
||||
.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<ArticleListRow> = 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::<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))
|
||||
}
|
||||
|
||||
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)]
|
||||
#[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<crate::interests::Interest>,
|
||||
pager: Pager,
|
||||
}
|
||||
|
||||
@@ -319,11 +388,24 @@ async fn list(
|
||||
) -> Result<Response, WebError> {
|
||||
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</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]
|
||||
async fn article_detail_shows_assessments_run_history_and_rating_events() {
|
||||
let seed = seed().await;
|
||||
|
||||
@@ -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<String>,
|
||||
interests: Vec<WhyInterest>,
|
||||
articles: Vec<WhyArticle>,
|
||||
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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
.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<UnratedPick>,
|
||||
active_jobs: Vec<JobLine>,
|
||||
finished_jobs: Vec<JobLine>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -322,6 +322,7 @@ impl Page {
|
||||
| "runs"
|
||||
| "articles"
|
||||
| "ratings"
|
||||
| "interests"
|
||||
| "profile"
|
||||
| "stats"
|
||||
| "jobs"
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
{% 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.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
|
||||
</div>{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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>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>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>
|
||||
@@ -18,11 +19,12 @@
|
||||
</form>
|
||||
{% 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>
|
||||
<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>
|
||||
<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-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="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>
|
||||
@@ -32,6 +34,6 @@
|
||||
<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(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" %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<tbody>{% for row in rows %}<tr>
|
||||
<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">{% 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="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>
|
||||
|
||||
@@ -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">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">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>
|
||||
|
||||
|
||||
@@ -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 == "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 == "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 == "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>
|
||||
|
||||
Reference in New Issue
Block a user