//! Dashboard: articles list and article detail (dashboard plan §9.3). //! //! The list joins every article to its best entry, its **latest** //! `candidate_runs` row (via `idx_candidate_runs_article_run`), both //! assessments, the current explicit rating and the latest publication. The //! detail page shows everything the system knows about one article, in the //! order of §9.3, compares its embedding with every compatible cached article, //! and wraps `telemetry::render_explain` verbatim in `
`.
use std::collections::HashMap;
use askama::Template;
use axum::Router;
use axum::extract::{Extension, Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use jiff::civil::Date;
use serde::Deserialize;
use sqlx::Row as _;
use super::{
Bind, Pager, REASONS, STAGES, SignalsView, admitted_by_parts, allow_listed, bind_all, db_err,
dynamic_query, fmt_opt, fmt_opt_int, fmt_stored_time, like_pattern, non_empty, page_number,
widget_label,
};
use crate::config::Config;
use crate::curate::embedding::{self, decode_blob};
use crate::curate::telemetry;
use crate::curate::triage::{PROVIDER_REJECTED, TRIAGE_KINDS};
use crate::db::Db;
use crate::server::AppState;
use crate::types::ArticleId;
use crate::web::rate::RatingWidget;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, Pagination, WebError, take_flash};
const ARTICLES_PER_PAGE: u32 = 50;
const NEAREST_ARTICLES: usize = 10;
const CURRENT_RATING_LOOKBACK_DAYS: i64 = 36_500;
/// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router {
Router::new()
.route("/dashboard/articles", get(list))
.route("/dashboard/articles/{id}", get(detail))
}
// ---------------------------------------------------------------------------
// Articles list
// ---------------------------------------------------------------------------
#[derive(Debug, Default, Deserialize)]
pub struct ArticlesQuery {
pub q: Option,
pub interest: Option,
pub feed: Option,
pub stage: Option,
pub reason: Option,
pub rated: Option,
pub published: Option,
pub from: Option,
pub to: Option,
pub kind: Option,
pub sort: Option,
pub page: Option,
}
const RATED: [&str; 7] = ["any", "loved", "good", "down", "slop", "cleared", "none"];
const PUBLISHED: [&str; 2] = ["yes", "no"];
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"),
("triage", "x.triage DESC, x.id DESC"),
("words", "x.word_count DESC, x.id DESC"),
("title", "x.title COLLATE NOCASE ASC, x.id ASC"),
];
/// Validated article filters; see [`CandidateFilters`](super::runs::CandidateFilters).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArticleFilters {
pub q: Option,
pub interest: Option,
pub feed: Option,
pub stage: Option,
pub reason: Option,
pub rated: Option,
pub published: Option,
pub from: Option,
pub to: Option,
pub kind: Option,
pub sort: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterestFilter {
pub id: Option,
pub name: String,
}
impl ArticleFilters {
pub fn from_query(query: &ArticlesQuery, interest: Option) -> Self {
let owned = |value: Option<&str>| value.map(str::to_string);
let date = |value: Option<&str>| {
non_empty(value)
.and_then(|value| value.parse::().ok())
.map(|date| date.to_string())
};
let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
kinds.push(PROVIDER_REJECTED);
let sort = ARTICLE_SORTS
.iter()
.find(|(name, _)| Some(*name) == query.sort.as_deref())
.map(|(name, _)| *name)
.filter(|sort| *sort != "match" || interest.is_some())
.unwrap_or(if interest.is_some() {
"match"
} else {
ARTICLE_SORTS[0].0
});
Self {
q: owned(non_empty(query.q.as_deref())),
interest,
feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::().ok()),
stage: owned(allow_listed(query.stage.as_deref(), &STAGES)),
reason: owned(allow_listed(query.reason.as_deref(), &REASONS)),
rated: owned(allow_listed(query.rated.as_deref(), &RATED)),
published: owned(allow_listed(query.published.as_deref(), &PUBLISHED)),
from: date(query.from.as_deref()),
to: date(query.to.as_deref()),
kind: owned(allow_listed(query.kind.as_deref(), &kinds)),
sort,
}
}
fn order_by(&self) -> &'static str {
ARTICLE_SORTS
.iter()
.find(|(name, _)| *name == self.sort)
.map(|(_, sql)| *sql)
.unwrap_or(ARTICLE_SORTS[0].1)
}
fn where_clauses(&self) -> (String, Vec) {
let mut sql = String::new();
let mut binds = Vec::new();
if let Some(q) = &self.q {
sql.push_str(" AND (x.title LIKE ? ESCAPE '\\' OR x.canonical_url LIKE ? ESCAPE '\\')");
binds.push(Bind::Text(like_pattern(q)));
binds.push(Bind::Text(like_pattern(q)));
}
if let Some(feed) = self.feed {
sql.push_str(" AND x.feed_id = ?");
binds.push(Bind::Int(feed));
}
if let Some(stage) = &self.stage {
sql.push_str(" AND x.stage = ?");
binds.push(Bind::Text(stage.clone()));
}
if let Some(reason) = &self.reason {
sql.push_str(" AND x.excluded_reason = ?");
binds.push(Bind::Text(reason.clone()));
}
match self.rated.as_deref() {
Some("any") => sql.push_str(" AND x.rating IS NOT NULL AND x.rating != 'cleared'"),
Some("none") => sql.push_str(" AND x.rating IS NULL"),
Some(label @ ("loved" | "good" | "slop" | "cleared")) => {
sql.push_str(" AND x.rating = ?");
binds.push(Bind::Text(label.to_string()));
}
Some("down") => sql.push_str(" AND x.rating = 'not_for_me'"),
_ => {}
}
match self.published.as_deref() {
Some("yes") => sql.push_str(" AND x.published IS NOT NULL"),
Some("no") => sql.push_str(" AND x.published IS NULL"),
_ => {}
}
if let Some(from) = &self.from {
sql.push_str(" AND substr(x.first_seen, 1, 10) >= ?");
binds.push(Bind::Text(from.clone()));
}
if let Some(to) = &self.to {
sql.push_str(" AND substr(x.first_seen, 1, 10) <= ?");
binds.push(Bind::Text(to.clone()));
}
if let Some(kind) = &self.kind {
sql.push_str(" AND x.triage_kind = ?");
binds.push(Bind::Text(kind.clone()));
}
(sql, binds)
}
fn params(&self) -> Vec<(&'static str, Option)> {
vec![
("q", self.q.clone()),
(
"interest",
self.interest.as_ref().map(|interest| interest.name.clone()),
),
("feed", self.feed.map(|feed| feed.to_string())),
("stage", self.stage.clone()),
("reason", self.reason.clone()),
("rated", self.rated.clone()),
("published", self.published.clone()),
("from", self.from.clone()),
("to", self.to.clone()),
("kind", self.kind.clone()),
(
"sort",
(self.sort != ARTICLE_SORTS[0].0).then(|| self.sort.to_string()),
),
]
}
}
/// One row of the articles table.
#[derive(Debug, Clone)]
pub struct ArticleListRow {
pub id: ArticleId,
pub href: String,
pub title: String,
pub feed_id: Option,
pub feed: String,
pub first_seen: String,
pub words: i64,
pub stage: Option,
pub reason: Option,
pub run_id: Option,
pub run_date: Option,
pub utility: String,
pub triage: String,
pub quality: String,
pub fit: String,
pub rating: Option,
pub rating_class: &'static str,
pub published: Option,
pub interests: Vec,
}
#[derive(Debug, Clone)]
pub struct ArticleInterest {
pub name: String,
pub href: String,
}
const ARTICLE_INNER: &str =
"SELECT a.id, COALESCE(a.title, '') AS title, a.canonical_url, a.first_seen,
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
WHERE c2.article_id = a.id)
LEFT JOIN runs r ON r.id = l.run_id
LEFT JOIN article_assessments t ON t.article_id = a.id AND t.stage = 'triage'
LEFT JOIN article_assessments d ON d.article_id = a.id AND d.stage = 'deep'";
pub async fn list_articles(
db: &Db,
config: &Config,
filters: &ArticleFilters,
page: u32,
) -> anyhow::Result<(Vec, Pagination)> {
let (clauses, binds) = filters.where_clauses();
let (inner, interest_binds) = article_inner(filters);
let all_binds = interest_binds
.iter()
.cloned()
.chain(binds.iter().cloned())
.collect::>();
let count_sql = format!("SELECT COUNT(*) FROM ({inner}) x WHERE 1 = 1{clauses}");
let total: i64 = bind_all(dynamic_query(count_sql), &all_binds)
.fetch_one(db.pool())
.await?
.get(0);
let pagination = Pagination {
page,
per_page: ARTICLES_PER_PAGE,
total,
};
let select_sql = format!(
"SELECT * FROM ({inner}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
filters.order_by()
);
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 mut rows: Vec = rows
.iter()
.map(|row| {
let id: ArticleId = row.get("id");
let rating: Option = row.get("rating");
ArticleListRow {
id,
href: format!("/dashboard/articles/{id}"),
title: row.get("title"),
feed_id: row.get("feed_id"),
feed: row.get("feed_title"),
first_seen: fmt_stored_time(Some(&row.get::("first_seen")), config),
words: row.get("word_count"),
stage: row.get("stage"),
reason: row.get("excluded_reason"),
run_id: row.get("run_id"),
run_date: row.get("run_date"),
utility: fmt_opt(row.get("utility"), 1),
triage: fmt_opt(row.get("triage"), 1),
quality: fmt_opt(row.get("quality"), 1),
fit: fmt_opt(row.get("fit"), 1),
rating_class: widget_label(rating.as_deref()),
rating,
published: row.get("published"),
interests: Vec::new(),
}
})
.collect();
let ids = rows.iter().map(|row| row.id).collect::>();
let mut by_article: HashMap> = HashMap::new();
for matched in crate::interests::matches_for_articles(db, &ids).await? {
by_article
.entry(matched.article_id)
.or_default()
.push(ArticleInterest {
href: crate::interests::articles_href(&matched.name),
name: matched.name,
});
}
for row in &mut rows {
row.interests = by_article.remove(&row.id).unwrap_or_default();
}
Ok((rows, pagination))
}
fn article_inner(filters: &ArticleFilters) -> (String, Vec) {
match &filters.interest {
Some(interest) => (
ARTICLE_INNER.replace("{match_cos}", "ai.cos").replace(
"{interest_join}",
"JOIN article_interests ai ON ai.article_id = a.id AND ai.interest_id = ?",
),
vec![Bind::Int(interest.id.unwrap_or(-1))],
),
None => (
ARTICLE_INNER
.replace("{match_cos}", "NULL")
.replace("{interest_join}", ""),
Vec::new(),
),
}
}
#[derive(Template)]
#[template(path = "dashboard/articles.html")]
struct ArticlesTemplate {
page: Page,
articles: Vec,
filters: ArticleFilters,
feed_value: String,
stages: Vec<&'static str>,
reasons: Vec<&'static str>,
rated: Vec<&'static str>,
kinds: Vec<&'static str>,
sorts: Vec<&'static str>,
interest_options: Vec,
pager: Pager,
}
async fn list(
State(state): State,
auth: AuthSession,
Extension(session): Extension,
Query(query): Query,
) -> Result {
let viewer = auth.user().await.map(Viewer::from);
let config = state.config();
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(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);
let mut page = Page::new("Articles", viewer, "articles");
page.flash = take_flash(&session).await?;
Ok(Html(ArticlesTemplate {
page,
articles,
feed_value: filters
.feed
.map(|feed| feed.to_string())
.unwrap_or_default(),
filters,
stages: STAGES.to_vec(),
reasons: REASONS.to_vec(),
rated: RATED.to_vec(),
kinds,
sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(),
interest_options,
pager,
})
.into_response())
}
// ---------------------------------------------------------------------------
// Article detail
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Facet {
pub name: String,
pub value: String,
}
/// One `article_assessments` row.
#[derive(Debug, Clone)]
pub struct AssessmentView {
pub stage: String,
pub rejected: bool,
pub model: String,
pub prompt_version: i64,
pub profile_version: String,
pub score: String,
pub fit: String,
pub kind: String,
pub rationale: String,
pub category: String,
pub paywalled: bool,
pub assessed_at: String,
pub facets: Vec,
}
/// One `candidate_runs` row of the article, newest first.
#[derive(Debug, Clone)]
pub struct HistoryRow {
pub run_id: i64,
pub run_href: String,
pub date: String,
pub status: String,
pub stage: String,
pub reason: Option,
pub admitted_first: Option,
pub admitted_rest: String,
pub utility: String,
pub rank: String,
pub cluster: String,
pub signals: SignalsView,
}
#[derive(Debug, Clone)]
pub struct EmbeddingView {
pub model: String,
pub dimension: i64,
pub created_at: String,
pub input_hash: String,
}
#[derive(Debug, Clone)]
struct StoredEmbedding {
view: EmbeddingView,
vector: Vec,
}
#[derive(Debug, Clone)]
struct NearestArticleView {
id: ArticleId,
cosine: String,
title: String,
feed: String,
first_seen: String,
rating: Option,
rating_class: &'static str,
}
#[derive(Debug, Clone)]
pub struct RatingEventView {
pub id: i64,
pub event_at: String,
pub issue_date: Option,
pub kind: String,
pub source: String,
pub label: String,
pub label_class: &'static str,
pub value: String,
pub note: Option,
pub user: Option,
}
#[derive(Debug, Clone)]
struct InIssue {
date: String,
href: String,
section: String,
position: i64,
is_lead: bool,
}
#[derive(Debug, Clone)]
struct SourceLine {
kind: String,
feed: String,
category: Option,
}
#[derive(Debug, Clone)]
struct SocialLine {
source: String,
score: i64,
comments: i64,
url: Option,
}
#[derive(Template)]
#[template(path = "dashboard/article.html")]
struct ArticleTemplate {
page: Page,
id: ArticleId,
title: String,
url: String,
canonical_url: String,
feed: String,
feed_id: i64,
category: Option,
author: Option,
published_at: String,
first_seen: String,
words: i64,
excerpt_only: bool,
image_count: i64,
sources: Vec,
social: Vec,
in_issues: Vec,
rating: Option,
rating_class: &'static str,
widget: RatingWidget,
explain: Option,
assessments: Vec,
history: Vec,
latest_signals: Option,
nearest_articles: Option>,
embedding: Option,
events: Vec,
}
pub async fn assessments(
db: &Db,
article_id: ArticleId,
config: &Config,
) -> Result, sqlx::Error> {
let rows = sqlx::query(
"SELECT stage, model, prompt_version, profile_version, score, fit, kind, facets_json,
rationale, category, paywalled_guess, assessed_at
FROM article_assessments WHERE article_id = ? ORDER BY stage DESC",
)
.bind(article_id)
.fetch_all(db.pool())
.await?;
Ok(rows
.iter()
.map(|row| {
let kind: Option = row.get("kind");
let facets = row
.get::