Merge branch 'step3' into web-dashboard
# Conflicts: # src/web/static/app.css
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# Step 3 handoff — dashboard reads: overview, runs, articles
|
||||
|
||||
## Landed
|
||||
|
||||
- `src/web/dashboard/mod.rs`: the overview (§9.1) — last-run card built from
|
||||
`runs.report_json` via `RunReport::info_block()` (legacy-counter fallback
|
||||
when the report is missing or unparseable), budget-today `<meter>` per
|
||||
referenced provider plus voyage from `db::provider_spend_for_utc_day(now)`,
|
||||
ratings this week by label, "Unrated picks" (last three issues' picks with
|
||||
no explicit event, each with the step 2 rating widget posting back to
|
||||
`/dashboard`), a jobs summary read straight from the `jobs` table (active
|
||||
rows plus the last five finished; the pages themselves are step 6), and the
|
||||
`! ` lines of `Config::check_report`. Sparklines are a marked HTML comment
|
||||
for step 6. The module also carries the helpers the read pages share:
|
||||
`Pager`, `SignalsView` (the parsed `signals_json` behind
|
||||
`_signals_table.html`), `allow_listed`, `like_pattern`, `Bind`/`bind_all`/
|
||||
`dynamic_query`, formatting helpers, and the `STAGES`/`REASONS`/
|
||||
`RETRIEVERS` allow-lists.
|
||||
- `src/web/dashboard/runs.rs` (§9.2): `/dashboard/runs` with `?status=` and
|
||||
pagination (50), and `/dashboard/runs/{id}` with header (issue link when the
|
||||
issue exists, previous/next run by id), the funnel from `candidate_runs`
|
||||
grouped by stage and reason, admission mix, preference state, timings,
|
||||
provider usage, warnings (`#warnings` anchor used by the overview and the
|
||||
list), top-20 feeds, the config diff (`flatten_json` + `config_diff`)
|
||||
against the previous non-dry run that recorded a config, ten near misses via
|
||||
`telemetry::near_misses`, and the candidates table (100 per page) with the
|
||||
`stage`/`reason`/`admitted_by`/`q`/`flag` filters and `utility` (default)/
|
||||
`rank`/`triage`/`quality`/`fit`/`title` sorts. Each row's title is a
|
||||
`<details>` that expands `_signals_table.html`.
|
||||
- `src/web/dashboard/articles.rs` (§9.3): `/dashboard/articles` (50 per page)
|
||||
joining the best entry, the latest `candidate_runs` row through the
|
||||
`MAX(run_id)` subquery (the index `idx_candidate_runs_article_run` is in
|
||||
migration 0004 — confirmed), both assessments, the current explicit rating
|
||||
and the latest publication; every listed filter and sort;
|
||||
`/dashboard/articles/{id}` with the six blocks in the plan's order,
|
||||
`provider_rejected` rows called out as such, run history rows expanding
|
||||
their signals, neighbours/interests from the latest row, embedding metadata
|
||||
only (never the vector), all rating events with source/user/note/value, the
|
||||
rating widget with `show_note = true`, and "Explain (text)" wrapping
|
||||
`telemetry::render_explain` verbatim in `<pre>`.
|
||||
- Templates: `dashboard/overview.html`, `runs.html`, `run.html`,
|
||||
`articles.html`, `article.html`, partials `_signals_table.html`,
|
||||
`_candidate_row.html` and `dashboard/_pager.html`. Every table sits in
|
||||
`.scroll-x`; badges per stage/reason/label/status; tables carry
|
||||
`data-filter`.
|
||||
- `app.css` / `app.js`: one appended block each (`/* step 3 … */`): cards,
|
||||
filters, funnel bar, pager, signals details, diff colours, and the
|
||||
filter-as-you-type behaviour for `table[data-filter]` (inserts a search
|
||||
input before the table's `.scroll-x` wrapper, hides non-matching rows on
|
||||
the current page only).
|
||||
|
||||
## Deviations and notes
|
||||
|
||||
- **sqlx 0.9 dynamic SQL.** `sqlx::query` only accepts `&'static str` or
|
||||
`AssertSqlSafe`, so the list queries go through `dashboard::dynamic_query`,
|
||||
which wraps the assembled string in `AssertSqlSafe`. The audit holds
|
||||
because the string is built only from constants and allow-listed fragments
|
||||
(`CANDIDATE_SORTS`/`ARTICLE_SORTS` map names to fixed `ORDER BY` text);
|
||||
every user value is bound through `bind_all`. An unknown sort or filter
|
||||
value falls back to the default / is dropped, never an error.
|
||||
- **Funnel semantics.** `stage` records where a row stopped, so the bars
|
||||
show the cumulative "reached" count (rows at this stage or a later one, so
|
||||
the first bar is everything considered) with "stopped here" and the reason
|
||||
breakdown beside it. Widths are `<svg width="N%">` children of `.funnel`
|
||||
because the CSP (`style-src 'self'`) forbids inline `style` attributes;
|
||||
the budget card uses `<meter>` for the same reason.
|
||||
- **Prev/next run** links go to the neighbouring run ids overall (the
|
||||
header already links the date's issue); a rerun of the same date is
|
||||
therefore the immediate neighbour.
|
||||
- **Extract method** is not shown on the article page: `db::article_from_row`
|
||||
hard-codes `ExtractMethod::Miniflux` for every loaded article, so the value
|
||||
would be meaningless. `excerpt_only` is shown instead. Source `kind`s render
|
||||
via their `Debug` names (`Feed`, `Scour`, …).
|
||||
- **`admitted_by` filter** is allow-listed against the six retriever names
|
||||
and still applied as the plan's prefix `LIKE` on
|
||||
`json_extract(admitted_by, '$[0]')`.
|
||||
- The `_pagination.html` partial from step 1 (text only) was left alone; the
|
||||
dashboard uses its own `dashboard/_pager.html` with prev/next links that
|
||||
preserve the other query parameters.
|
||||
- Only additive edits outside my files: two appended blocks in `app.css` and
|
||||
`app.js`. No changes to `web/mod.rs`, `db.rs`, `rate.rs` or `telemetry.rs`
|
||||
(a private `first_retriever` in telemetry was reimplemented as
|
||||
`admitted_by_parts` rather than made `pub`).
|
||||
|
||||
## Left for later steps
|
||||
|
||||
- Step 6: overview sparklines (placeholder comment in `overview.html`), and
|
||||
the `/dashboard/jobs` pages the overview's job links point at.
|
||||
- Step 4: `/dashboard/ratings`, linked from the overview's ratings card.
|
||||
- The overview's ratings-this-week card counts all explicit events in the
|
||||
last seven days by label (a `cleared` is listed but not counted in the
|
||||
total).
|
||||
|
||||
## Verification (outside the sandbox)
|
||||
|
||||
- `cargo fmt`: pass.
|
||||
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||
- `cargo test web::dashboard`: **11 passed, 0 failed**.
|
||||
- `cargo test` (full suite): **387 lib tests passed, 0 failed**, plus every
|
||||
integration test binary green (7, 2, 3, 4, 7, 9, 2), nothing skipped.
|
||||
@@ -1,10 +1,932 @@
|
||||
//! Dashboard: articles pages. Filled in by web dashboard plan step 3.
|
||||
//! 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, and wraps `telemetry::render_explain` verbatim in `<pre>`.
|
||||
|
||||
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::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;
|
||||
|
||||
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||
pub fn routes() -> Router<AppState> {
|
||||
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<String>,
|
||||
pub feed: Option<String>,
|
||||
pub stage: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub rated: Option<String>,
|
||||
pub published: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub page: Option<u32>,
|
||||
}
|
||||
|
||||
const RATED: [&str; 6] = ["any", "loved", "good", "down", "cleared", "none"];
|
||||
const PUBLISHED: [&str; 2] = ["yes", "no"];
|
||||
|
||||
const ARTICLE_SORTS: [(&str, &str); 7] = [
|
||||
("first_seen", "x.first_seen 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<String>,
|
||||
pub feed: Option<i64>,
|
||||
pub stage: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub rated: Option<String>,
|
||||
pub published: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub sort: &'static str,
|
||||
}
|
||||
|
||||
impl ArticleFilters {
|
||||
pub fn from_query(query: &ArticlesQuery) -> Self {
|
||||
let owned = |value: Option<&str>| value.map(str::to_string);
|
||||
let date = |value: Option<&str>| {
|
||||
non_empty(value)
|
||||
.and_then(|value| value.parse::<Date>().ok())
|
||||
.map(|date| date.to_string())
|
||||
};
|
||||
let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
|
||||
kinds.push(PROVIDER_REJECTED);
|
||||
Self {
|
||||
q: owned(non_empty(query.q.as_deref())),
|
||||
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)),
|
||||
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: ARTICLE_SORTS
|
||||
.iter()
|
||||
.find(|(name, _)| Some(*name) == query.sort.as_deref())
|
||||
.map(|(name, _)| *name)
|
||||
.unwrap_or(ARTICLE_SORTS[0].0),
|
||||
}
|
||||
}
|
||||
|
||||
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<Bind>) {
|
||||
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" | "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<String>)> {
|
||||
vec![
|
||||
("q", self.q.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<i64>,
|
||||
pub feed: String,
|
||||
pub first_seen: String,
|
||||
pub words: i64,
|
||||
pub stage: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub run_id: Option<i64>,
|
||||
pub run_date: Option<String>,
|
||||
pub utility: String,
|
||||
pub triage: String,
|
||||
pub quality: String,
|
||||
pub fit: String,
|
||||
pub rating: Option<String>,
|
||||
pub rating_class: &'static str,
|
||||
pub published: Option<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,
|
||||
(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
|
||||
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,
|
||||
) -> Result<(Vec<ArticleListRow>, Pagination), sqlx::Error> {
|
||||
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)
|
||||
.fetch_one(db.pool())
|
||||
.await?
|
||||
.get(0);
|
||||
let pagination = Pagination {
|
||||
page,
|
||||
per_page: ARTICLES_PER_PAGE,
|
||||
total,
|
||||
};
|
||||
let select_sql = format!(
|
||||
"SELECT * FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
|
||||
filters.order_by()
|
||||
);
|
||||
let rows = bind_all(dynamic_query(select_sql), &binds)
|
||||
.bind(i64::from(ARTICLES_PER_PAGE))
|
||||
.bind(pagination.offset())
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let rows = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let id: ArticleId = row.get("id");
|
||||
let rating: Option<String> = 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::<String, _>("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"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((rows, pagination))
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dashboard/articles.html")]
|
||||
struct ArticlesTemplate {
|
||||
page: Page,
|
||||
articles: Vec<ArticleListRow>,
|
||||
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>,
|
||||
pager: Pager,
|
||||
}
|
||||
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
Query(query): Query<ArticlesQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth.user().await.map(Viewer::from);
|
||||
let config = state.config();
|
||||
let filters = ArticleFilters::from_query(&query);
|
||||
let page_no = page_number(query.page);
|
||||
let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
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(),
|
||||
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<Facet>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub admitted_first: Option<String>,
|
||||
pub admitted_rest: String,
|
||||
pub utility: String,
|
||||
pub rank: String,
|
||||
pub cluster: String,
|
||||
pub editor_why: Option<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)]
|
||||
pub struct RatingEventView {
|
||||
pub id: i64,
|
||||
pub event_at: String,
|
||||
pub issue_date: Option<String>,
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
pub label: String,
|
||||
pub label_class: &'static str,
|
||||
pub value: String,
|
||||
pub note: Option<String>,
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SocialLine {
|
||||
source: String,
|
||||
score: i64,
|
||||
comments: i64,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
author: Option<String>,
|
||||
published_at: String,
|
||||
first_seen: String,
|
||||
words: i64,
|
||||
excerpt_only: bool,
|
||||
image_count: i64,
|
||||
sources: Vec<SourceLine>,
|
||||
social: Vec<SocialLine>,
|
||||
in_issues: Vec<InIssue>,
|
||||
rating: Option<String>,
|
||||
rating_class: &'static str,
|
||||
widget: RatingWidget,
|
||||
explain: Option<String>,
|
||||
assessments: Vec<AssessmentView>,
|
||||
history: Vec<HistoryRow>,
|
||||
latest_signals: Option<SignalsView>,
|
||||
embedding: Option<EmbeddingView>,
|
||||
events: Vec<RatingEventView>,
|
||||
}
|
||||
|
||||
pub async fn assessments(
|
||||
db: &Db,
|
||||
article_id: ArticleId,
|
||||
config: &Config,
|
||||
) -> Result<Vec<AssessmentView>, 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<String> = row.get("kind");
|
||||
let facets = row
|
||||
.get::<Option<String>, _>("facets_json")
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.map(|object| {
|
||||
object
|
||||
.into_iter()
|
||||
.map(|(name, value)| Facet {
|
||||
name,
|
||||
value: match value {
|
||||
serde_json::Value::String(text) => text,
|
||||
serde_json::Value::Array(items) => items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| item.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
other => other.to_string(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
AssessmentView {
|
||||
stage: row.get("stage"),
|
||||
rejected: kind.as_deref() == Some(PROVIDER_REJECTED),
|
||||
model: row.get("model"),
|
||||
prompt_version: row.get("prompt_version"),
|
||||
profile_version: fmt_opt_int(row.get("profile_version")),
|
||||
score: fmt_opt(row.get("score"), 1),
|
||||
fit: fmt_opt(row.get("fit"), 1),
|
||||
kind: kind.unwrap_or_else(|| "—".into()),
|
||||
rationale: row
|
||||
.get::<Option<String>, _>("rationale")
|
||||
.unwrap_or_default(),
|
||||
category: row
|
||||
.get::<Option<String>, _>("category")
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
paywalled: row.get::<i64, _>("paywalled_guess") != 0,
|
||||
assessed_at: fmt_stored_time(Some(&row.get::<String, _>("assessed_at")), config),
|
||||
facets,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn run_history(db: &Db, article_id: ArticleId) -> Result<Vec<HistoryRow>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT cr.run_id, r.date, r.status, cr.stage, cr.excluded_reason, cr.admitted_by,
|
||||
cr.signals_json, cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank,
|
||||
cr.editor_why
|
||||
FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
|
||||
WHERE cr.article_id = ? ORDER BY cr.run_id DESC",
|
||||
)
|
||||
.bind(article_id)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let run_id: i64 = row.get("run_id");
|
||||
let (admitted_first, rest) =
|
||||
admitted_by_parts(row.get::<Option<String>, _>("admitted_by").as_deref());
|
||||
HistoryRow {
|
||||
run_id,
|
||||
run_href: format!("/dashboard/runs/{run_id}"),
|
||||
date: row.get("date"),
|
||||
status: row.get("status"),
|
||||
stage: row.get("stage"),
|
||||
reason: row.get("excluded_reason"),
|
||||
admitted_first,
|
||||
admitted_rest: rest.join(", "),
|
||||
utility: fmt_opt(row.get("utility"), 1),
|
||||
rank: fmt_opt_int(row.get("rank_utility")),
|
||||
cluster: match (
|
||||
row.get::<Option<i64>, _>("cluster_id"),
|
||||
row.get::<Option<i64>, _>("cluster_rank"),
|
||||
) {
|
||||
(Some(id), Some(rank)) => format!("{id} · {rank}"),
|
||||
(Some(id), None) => id.to_string(),
|
||||
_ => "—".into(),
|
||||
},
|
||||
editor_why: row.get("editor_why"),
|
||||
signals: SignalsView::from_json(&row.get::<String, _>("signals_json")),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn rating_events(
|
||||
db: &Db,
|
||||
article_id: ArticleId,
|
||||
config: &Config,
|
||||
) -> Result<Vec<RatingEventView>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT re.id, re.issue_date, re.kind, re.source, re.label, re.value, re.note,
|
||||
re.event_at, u.username
|
||||
FROM rating_events re LEFT JOIN users u ON u.id = re.user_id
|
||||
WHERE re.article_id = ? ORDER BY re.event_at DESC, re.id DESC",
|
||||
)
|
||||
.bind(article_id)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let label: String = row.get("label");
|
||||
RatingEventView {
|
||||
id: row.get("id"),
|
||||
event_at: fmt_stored_time(Some(&row.get::<String, _>("event_at")), config),
|
||||
issue_date: row.get("issue_date"),
|
||||
kind: row.get("kind"),
|
||||
source: row.get("source"),
|
||||
label_class: widget_label(Some(&label)),
|
||||
label,
|
||||
value: format!("{:.2}", row.get::<f64, _>("value")),
|
||||
note: row.get("note"),
|
||||
user: row.get("username"),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn embedding(
|
||||
db: &Db,
|
||||
article_id: ArticleId,
|
||||
config: &Config,
|
||||
) -> Result<Option<EmbeddingView>, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
"SELECT model, dimension, created_at, input_hash FROM article_embeddings
|
||||
WHERE article_id = ?",
|
||||
)
|
||||
.bind(article_id)
|
||||
.fetch_optional(db.pool())
|
||||
.await?;
|
||||
Ok(row.map(|row| EmbeddingView {
|
||||
model: row.get("model"),
|
||||
dimension: row.get("dimension"),
|
||||
created_at: fmt_stored_time(Some(&row.get::<String, _>("created_at")), config),
|
||||
input_hash: row.get("input_hash"),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn detail(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(id): Path<ArticleId>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth.user().await.map(Viewer::from);
|
||||
let config = state.config();
|
||||
let db = &state.db;
|
||||
let Some(article) = db.get_article(id).await? else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
|
||||
let in_issues = sqlx::query(
|
||||
"SELECT issue_date, section, position, is_lead FROM issue_articles
|
||||
WHERE article_id = ? ORDER BY issue_date DESC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let date: String = row.get("issue_date");
|
||||
InIssue {
|
||||
href: format!("/issues/{date}/articles/{id}"),
|
||||
date,
|
||||
section: row.get("section"),
|
||||
position: row.get("position"),
|
||||
is_lead: row.get("is_lead"),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let events = rating_events(db, id, &config).await.map_err(db_err)?;
|
||||
let rating = events
|
||||
.iter()
|
||||
.find(|event| event.kind == "explicit")
|
||||
.map(|event| event.label.clone());
|
||||
let widget = RatingWidget {
|
||||
article_id: id,
|
||||
issue_date: in_issues
|
||||
.first()
|
||||
.map(|issue| issue.date.clone())
|
||||
.unwrap_or_default(),
|
||||
next: format!("/dashboard/articles/{id}"),
|
||||
current: widget_label(rating.as_deref()).to_string(),
|
||||
show_note: true,
|
||||
};
|
||||
|
||||
let history = run_history(db, id).await.map_err(db_err)?;
|
||||
let explain = match history.first() {
|
||||
Some(latest) => match telemetry::explain_row(db, latest.run_id, id)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
{
|
||||
Some(row) => Some(telemetry::render_explain(db, &row).await.map_err(db_err)?),
|
||||
None => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let latest_signals = history
|
||||
.first()
|
||||
.map(|latest| latest.signals.clone())
|
||||
.filter(|signals| !signals.empty);
|
||||
let assessments = assessments(db, id, &config).await.map_err(db_err)?;
|
||||
let embedding = embedding(db, id, &config).await.map_err(db_err)?;
|
||||
|
||||
let mut page = Page::new(article.title.clone(), viewer, "articles");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(ArticleTemplate {
|
||||
page,
|
||||
id,
|
||||
title: article.title.clone(),
|
||||
url: article.url.clone(),
|
||||
canonical_url: article.canonical_url.clone(),
|
||||
feed: article.feed_title.clone(),
|
||||
feed_id: article.feed_id,
|
||||
category: article.category.clone(),
|
||||
author: article.author.clone(),
|
||||
published_at: article
|
||||
.published_at
|
||||
.map(|at| crate::web::format_time(at, &config))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
first_seen: crate::web::format_time(article.first_seen, &config),
|
||||
words: article.word_count,
|
||||
excerpt_only: article.excerpt_only,
|
||||
image_count: article.image_count,
|
||||
sources: article
|
||||
.sources
|
||||
.iter()
|
||||
.map(|source| SourceLine {
|
||||
kind: format!("{:?}", source.kind),
|
||||
feed: source.feed_title.clone(),
|
||||
category: source.category.clone(),
|
||||
})
|
||||
.collect(),
|
||||
social: article
|
||||
.social
|
||||
.iter()
|
||||
.map(|social| SocialLine {
|
||||
source: format!("{:?}", social.source).to_lowercase(),
|
||||
score: social.score,
|
||||
comments: social.num_comments,
|
||||
url: social.item_url.clone(),
|
||||
})
|
||||
.collect(),
|
||||
in_issues,
|
||||
rating_class: widget_label(rating.as_deref()),
|
||||
rating,
|
||||
widget,
|
||||
explain,
|
||||
assessments,
|
||||
history,
|
||||
latest_signals,
|
||||
embedding,
|
||||
events,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::web::dashboard::tests::{
|
||||
app_with_users, assert_admin_only, get, login_cookie, seed,
|
||||
};
|
||||
|
||||
fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters {
|
||||
let mut query = ArticlesQuery::default();
|
||||
f(&mut query);
|
||||
ArticleFilters::from_query(&query)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn articles_list_filters_and_sorts_are_allow_listed() {
|
||||
let seed = seed().await;
|
||||
let db = &seed.db;
|
||||
let config = Config::default();
|
||||
|
||||
let all = query(|_| {});
|
||||
assert_eq!(all.sort, "first_seen");
|
||||
let (rows, pagination) = list_articles(db, &config, &all, 1).await.unwrap();
|
||||
assert_eq!(pagination.total, 8);
|
||||
assert_eq!(rows.len(), 8);
|
||||
let one = rows.iter().find(|row| row.id == 1).unwrap();
|
||||
assert_eq!(one.stage.as_deref(), Some("selected"), "latest run's row");
|
||||
assert_eq!(one.run_id, Some(seed.run_id));
|
||||
assert_eq!(one.rating.as_deref(), Some("loved"));
|
||||
assert_eq!(one.published.as_deref(), Some("2026-09-02"));
|
||||
assert_eq!(one.quality, "8.8");
|
||||
|
||||
let bogus = query(|q| {
|
||||
q.sort = Some("id; DROP TABLE articles".into());
|
||||
q.stage = Some("nope".into());
|
||||
q.rated = Some("' OR 1=1".into());
|
||||
q.from = Some("not a date".into());
|
||||
q.feed = Some("abc".into());
|
||||
});
|
||||
assert_eq!(bogus.sort, "first_seen");
|
||||
assert_eq!(bogus.stage, None);
|
||||
assert_eq!(bogus.rated, None);
|
||||
assert_eq!(bogus.from, None);
|
||||
assert_eq!(bogus.feed, None);
|
||||
let (rows, _) = list_articles(db, &config, &bogus, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 8, "unknown values fall back, never error");
|
||||
|
||||
let by_feed = query(|q| q.feed = Some("20".into()));
|
||||
let (rows, _) = list_articles(db, &config, &by_feed, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert!(rows.iter().all(|row| row.feed == "Beta Weekly"));
|
||||
|
||||
let selected = query(|q| q.stage = Some("selected".into()));
|
||||
let (rows, _) = list_articles(db, &config, &selected, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
let reason = query(|q| q.reason = Some("blocked".into()));
|
||||
let (rows, _) = list_articles(db, &config, &reason, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, 7);
|
||||
|
||||
let loved = query(|q| q.rated = Some("loved".into()));
|
||||
let (rows, _) = list_articles(db, &config, &loved, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, 1);
|
||||
let good = query(|q| q.rated = Some("good".into()));
|
||||
let (rows, _) = list_articles(db, &config, &good, 1).await.unwrap();
|
||||
assert!(rows.is_empty(), "superseded events do not count");
|
||||
let unrated = query(|q| q.rated = Some("none".into()));
|
||||
let (rows, _) = list_articles(db, &config, &unrated, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 7);
|
||||
|
||||
let published = query(|q| q.published = Some("yes".into()));
|
||||
let (rows, _) = list_articles(db, &config, &published, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
let window = query(|q| {
|
||||
q.from = Some("2026-09-02".into());
|
||||
q.to = Some("2026-09-02".into());
|
||||
});
|
||||
let (rows, _) = list_articles(db, &config, &window, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 3, "ids 1, 4, 7 were first seen on the 2nd");
|
||||
|
||||
let kind = query(|q| q.kind = Some("provider_rejected".into()));
|
||||
let (rows, _) = list_articles(db, &config, &kind, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, 3);
|
||||
|
||||
let search = query(|q| q.q = Some("example.com/8".into()));
|
||||
let (rows, _) = list_articles(db, &config, &search, 1).await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, 8);
|
||||
|
||||
let by_words = query(|q| q.sort = Some("words".into()));
|
||||
let (rows, _) = list_articles(db, &config, &by_words, 1).await.unwrap();
|
||||
assert_eq!(rows[0].id, 8);
|
||||
let by_utility = query(|q| q.sort = Some("utility".into()));
|
||||
let (rows, _) = list_articles(db, &config, &by_utility, 1).await.unwrap();
|
||||
assert_eq!(rows[0].id, 1);
|
||||
assert_eq!(rows[7].utility, "—");
|
||||
let by_title = query(|q| q.sort = Some("title".into()));
|
||||
let (rows, _) = list_articles(db, &config, &by_title, 1).await.unwrap();
|
||||
assert_eq!(rows[0].id, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn article_detail_shows_assessments_run_history_and_rating_events() {
|
||||
let seed = seed().await;
|
||||
let config = Config::default();
|
||||
let views = assessments(&seed.db, 1, &config).await.unwrap();
|
||||
assert_eq!(views.len(), 2);
|
||||
assert_eq!(views[0].stage, "triage");
|
||||
assert_eq!(views[0].score, "7.0");
|
||||
assert_eq!(views[1].stage, "deep");
|
||||
assert_eq!(views[1].fit, "6.0");
|
||||
assert_eq!(views[1].category, "Top Stories");
|
||||
assert_eq!(views[1].facets[0].name, "depth");
|
||||
let rejected = assessments(&seed.db, 3, &config).await.unwrap();
|
||||
assert!(rejected[0].rejected);
|
||||
|
||||
let history = run_history(&seed.db, 1).await.unwrap();
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].run_id, seed.run_id);
|
||||
assert_eq!(history[0].stage, "selected");
|
||||
assert_eq!(history[0].admitted_first.as_deref(), Some("triage"));
|
||||
assert_eq!(history[1].run_id, seed.earlier_run_id);
|
||||
assert!(history[1].signals.empty);
|
||||
|
||||
let events = rating_events(&seed.db, 1, &config).await.unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].label, "loved");
|
||||
assert_eq!(events[1].label, "good");
|
||||
assert_eq!(events[1].note.as_deref(), Some("note good"));
|
||||
|
||||
let app = app_with_users(&seed.db).await;
|
||||
let list = assert_admin_only(&app, "/dashboard/articles").await;
|
||||
assert!(list.contains("Article 1 about prose"), "{list}");
|
||||
assert!(list.contains("/dashboard/articles/1"), "{list}");
|
||||
|
||||
let body = assert_admin_only(&app, "/dashboard/articles/1").await;
|
||||
assert!(body.contains("Careful and first-hand"), "{body}");
|
||||
assert!(body.contains("A specific argument"), "{body}");
|
||||
assert!(body.contains("topic_group"), "{body}");
|
||||
assert!(
|
||||
body.contains(&format!("/dashboard/runs/{}", seed.earlier_run_id)),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("note good"), "{body}");
|
||||
assert!(body.contains("note loved"), "{body}");
|
||||
assert!(body.contains("voyage-4-lite"), "{body}");
|
||||
assert!(body.contains("abc123"), "{body}");
|
||||
assert!(body.contains("Gaussian Splatting"), "{body}");
|
||||
assert!(
|
||||
body.contains("article 1: Article 1 about prose"),
|
||||
"explain: {body}"
|
||||
);
|
||||
assert!(body.contains("name=\"note\""), "note field: {body}");
|
||||
assert!(
|
||||
body.contains("value=\"loved\" data-label=\"loved\" class=\"active\""),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("Top Stories"), "{body}");
|
||||
assert!(body.contains("Alpha Blog"), "{body}");
|
||||
|
||||
let rejected = assert_admin_only(&app, "/dashboard/articles/3").await;
|
||||
assert!(rejected.contains("rejected by provider"), "{rejected}");
|
||||
assert!(rejected.contains("Content Exists Risk"), "{rejected}");
|
||||
|
||||
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||
let missing = get(&app, "/dashboard/articles/999", Some(&admin)).await;
|
||||
assert_eq!(missing.status(), axum::http::StatusCode::NOT_FOUND);
|
||||
let filtered = get(
|
||||
&app,
|
||||
"/dashboard/articles?rated=loved&sort=nope&feed=x&page=0",
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
+1100
-4
File diff suppressed because it is too large
Load Diff
+1124
-1
File diff suppressed because it is too large
Load Diff
@@ -73,3 +73,37 @@ pre.preview { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-m
|
||||
.versions pre.preview { max-height:6rem; border:0; padding:0; }
|
||||
.versions form { margin:0; }
|
||||
@media (max-width:60rem) { .profile-grid { display:block; } }
|
||||
||||||| 849231e
|
||||
/* step 3: dashboard reads (overview, runs, articles) */
|
||||
.dashboard h1 { font-size:1.5rem; margin:.5rem 0; }
|
||||
.dashboard h1 a { color:inherit; }
|
||||
.dashboard h2 { font-size:1.1rem; margin:1.75rem 0 .5rem; }
|
||||
.dashboard h3 { font-size:1rem; margin:.5rem 0; }
|
||||
.dashboard table td { vertical-align:top; }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(20rem,1fr)); gap:1rem; margin:1rem 0; }
|
||||
.card { border:1px solid var(--rule); padding:.75rem 1rem; min-width:0; }
|
||||
.card h2 { margin-top:0; }
|
||||
.block { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; margin:.5rem 0; }
|
||||
.muted { color:var(--muted); }
|
||||
.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
.filters { display:flex; flex-wrap:wrap; gap:.5rem 1rem; align-items:end; margin:1rem 0; }
|
||||
.filters label { font-size:.85rem; }
|
||||
.filters input,.filters select { padding:.3rem; font-size:.9rem; }
|
||||
.run-nav { display:flex; flex-wrap:wrap; gap:1.5rem; margin:.5rem 0 1rem; }
|
||||
.funnel-cell { min-width:12rem; width:40%; }
|
||||
.funnel svg { display:block; }
|
||||
.funnel-table .reasons { color:var(--muted); font-size:.8rem; }
|
||||
.badge.reason,.badge.running,.badge.requested { color:var(--muted); }
|
||||
.badge.ok { color:var(--loved); } .badge.degraded { color:var(--good); } .badge.failed,.badge.dry_run { color:var(--down); }
|
||||
.pager { display:flex; flex-wrap:wrap; gap:1rem; margin:1rem 0; align-items:center; }
|
||||
details.signals summary { cursor:pointer; }
|
||||
details.signals summary a { display:inline; }
|
||||
.signals-body { margin:.5rem 0 .5rem 1rem; font-size:.85rem; }
|
||||
.signals-body table { width:auto; }
|
||||
.signals-body tr.muted td { color:var(--muted); }
|
||||
details.explain { margin:1rem 0; }
|
||||
.diff .before { color:var(--down); } .diff .after { color:var(--loved); }
|
||||
.picks,.jobs,.warnings { list-style:none; padding:0; }
|
||||
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
|
||||
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
|
||||
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
|
||||
|
||||
@@ -39,3 +39,21 @@ document.querySelectorAll("details[id]").forEach((details) => {
|
||||
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
|
||||
} catch (_) {}
|
||||
});
|
||||
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
|
||||
document.querySelectorAll("table[data-filter]").forEach((table) => {
|
||||
const rows = table.querySelectorAll("tbody tr");
|
||||
if (rows.length < 2) return;
|
||||
const input = document.createElement("input");
|
||||
input.type = "search";
|
||||
input.className = "table-filter";
|
||||
input.placeholder = "Filter rows on this page";
|
||||
input.setAttribute("aria-label", "Filter rows on this page");
|
||||
const host = table.closest(".scroll-x") || table;
|
||||
host.parentNode.insertBefore(input, host);
|
||||
input.addEventListener("input", () => {
|
||||
const needle = input.value.trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<tr>
|
||||
<td><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
||||
<td>{{ candidate.feed }}</td>
|
||||
<td class="num">{{ candidate.words }}</td>
|
||||
<td><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
||||
<td>{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
||||
<td>{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||
<td class="num">{{ candidate.utility }}</td>
|
||||
<td class="num">{{ candidate.rank }}</td>
|
||||
<td class="num">{{ candidate.cluster }}</td>
|
||||
<td class="num">{{ candidate.triage }}</td>
|
||||
<td class="num">{{ candidate.quality }}</td>
|
||||
<td class="num">{{ candidate.fit }}</td>
|
||||
<td>{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
||||
<td>{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
|
||||
<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 %}absent{% endif %}</td></tr>{% endfor %}</tbody></table>
|
||||
<p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
|
||||
{% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></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.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></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 %}
|
||||
@@ -0,0 +1 @@
|
||||
{% if pager.pages > 1 %}<nav class="pager">{% if let Some(href) = pager.prev_href %}<a href="{{ href }}">← Previous</a>{% endif %}<span>Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} rows</span>{% if let Some(href) = pager.next_href %}<a href="{{ href }}">Next →</a>{% endif %}</nav>{% else %}<p class="muted">{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}</p>{% endif %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<h1><a href="{{ url }}">{{ title }}</a></h1>
|
||||
<p class="muted">Article {{ id }} · <a href="/dashboard/articles?feed={{ feed_id }}">{{ feed }}</a>{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}</p>
|
||||
|
||||
<h2>Article</h2>
|
||||
<dl class="kv">
|
||||
<dt>Canonical URL</dt><dd><a href="{{ canonical_url }}">{{ canonical_url }}</a></dd>
|
||||
<dt>Published</dt><dd>{{ published_at }}</dd>
|
||||
<dt>First seen</dt><dd>{{ first_seen }}</dd>
|
||||
<dt>Words</dt><dd>{{ words }}{% if excerpt_only %} <span class="badge">excerpt only</span>{% endif %}</dd>
|
||||
<dt>Images</dt><dd>{{ image_count }}</dd>
|
||||
<dt>Sources</dt><dd>{% if sources.is_empty() %}<span class="muted">none recorded</span>{% else %}{% for source in sources %}{{ source.kind }} · {{ source.feed }}{% if let Some(category) = source.category %} ({{ category }}){% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||
<dt>Social</dt><dd>{% if social.is_empty() %}<span class="muted">none</span>{% else %}{% for item in social %}{% if let Some(url) = item.url %}<a href="{{ url }}">{{ item.source }}</a>{% else %}{{ item.source }}{% endif %} · {{ item.score }} points · {{ item.comments }} comments{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||
<dt>In issues</dt><dd>{% if in_issues.is_empty() %}<span class="muted">never published</span>{% else %}{% for issue in in_issues %}<a href="{{ issue.href }}">{{ issue.date }}</a> · {{ issue.section }} · position {{ issue.position }}{% if issue.is_lead %} · lead{% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||
<dt>Current rating</dt><dd>{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</dd>
|
||||
</dl>
|
||||
{% include "_rating_widget.html" %}
|
||||
{% if let Some(text) = explain %}<details class="explain"><summary>Explain (text)</summary><pre class="block">{{ text }}</pre></details>{% endif %}
|
||||
|
||||
<h2>Assessments</h2>
|
||||
{% if assessments.is_empty() %}<p class="muted">No LLM assessment stored.</p>{% else %}{% for assessment in assessments %}<section class="card">
|
||||
<h3>{{ assessment.stage }}{% if assessment.rejected %} <span class="badge down">rejected by provider</span>{% endif %}</h3>
|
||||
{% if assessment.rejected %}<p>{{ assessment.rationale }}</p><p class="muted">{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}</p>{% else %}<dl class="kv">
|
||||
{% if assessment.stage == "deep" %}<dt>Quality</dt><dd>{{ assessment.score }}</dd><dt>Fit</dt><dd>{{ assessment.fit }}</dd><dt>Category</dt><dd>{{ assessment.category }}</dd><dt>Format</dt><dd>{{ assessment.kind }}</dd><dt>Paywalled guess</dt><dd>{% if assessment.paywalled %}yes{% else %}no{% endif %}</dd>{% else %}<dt>Interest</dt><dd>{{ assessment.score }}</dd><dt>Kind</dt><dd>{{ assessment.kind }}</dd>{% endif %}
|
||||
<dt>Rationale</dt><dd>{{ assessment.rationale }}</dd>
|
||||
<dt>Model</dt><dd>{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · profile v{{ assessment.profile_version }}</dd>
|
||||
<dt>Assessed</dt><dd>{{ assessment.assessed_at }}</dd>
|
||||
</dl>
|
||||
{% if !assessment.facets.is_empty() %}<div class="scroll-x"><table class="facets"><thead><tr><th>facet</th><th>value</th></tr></thead><tbody>{% for facet in assessment.facets %}<tr><td>{{ facet.name }}</td><td>{{ facet.value }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% endif %}
|
||||
</section>{% endfor %}{% endif %}
|
||||
|
||||
<h2>Run history</h2>
|
||||
{% if history.is_empty() %}<p class="muted">Never considered by a run.</p>{% else %}<div class="scroll-x"><table class="history">
|
||||
<thead><tr><th>run</th><th>date</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th>editor</th></tr></thead>
|
||||
<tbody>{% for row in history %}<tr>
|
||||
<td><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
||||
<td>{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
||||
<td>{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||
<td class="num">{{ row.utility }}</td>
|
||||
<td class="num">{{ row.rank }}</td>
|
||||
<td class="num">{{ row.cluster }}</td>
|
||||
<td>{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
|
||||
<h2>Neighbours and interests</h2>
|
||||
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted">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>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><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||
</div>{% endif %}{% else %}<p class="muted">No signals recorded.</p>{% endif %}
|
||||
|
||||
<h2>Embedding</h2>
|
||||
{% if let Some(embedding) = embedding %}<dl class="kv"><dt>Model</dt><dd>{{ embedding.model }}</dd><dt>Dimension</dt><dd>{{ embedding.dimension }}</dd><dt>Created</dt><dd>{{ embedding.created_at }}</dd><dt>Input hash</dt><dd><code>{{ embedding.input_hash }}</code></dd></dl>{% else %}<p class="muted">No embedding stored.</p>{% endif %}
|
||||
|
||||
<h2>Rating events</h2>
|
||||
{% if events.is_empty() %}<p class="muted">No rating events.</p>{% else %}<div class="scroll-x"><table class="events">
|
||||
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
||||
<tbody>{% for event in events %}<tr>
|
||||
<td>{{ event.event_at }}</td>
|
||||
<td><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
||||
<td class="num">{{ event.value }}</td>
|
||||
<td>{{ event.kind }}</td>
|
||||
<td>{{ event.source }}</td>
|
||||
<td>{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td>{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||
<td>{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
</section>{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<h1>Articles</h1>
|
||||
<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 %}"></label>
|
||||
<label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></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>
|
||||
<label>Published <select name="published"><option value="">all</option><option value="yes"{% if let Some(current) = filters.published %}{% if current == "yes" %} selected{% endif %}{% endif %}>yes</option><option value="no"{% if let Some(current) = filters.published %}{% if current == "no" %} selected{% endif %}{% endif %}>no</option></select></label>
|
||||
<label>First seen from <input type="date" name="from" value="{% if let Some(from) = filters.from %}{{ from }}{% endif %}"></label>
|
||||
<label>to <input type="date" name="to" value="{% if let Some(to) = filters.to %}{{ to }}{% endif %}"></label>
|
||||
<label>Triage kind <select name="kind"><option value="">any</option>{% for name in kinds %}<option value="{{ name }}"{% if let Some(current) = filters.kind %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||
<button type="submit">Apply</button> <a href="/dashboard/articles">Reset</a>
|
||||
</form>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
<div class="scroll-x"><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>
|
||||
<tbody>{% for article in articles %}<tr>
|
||||
<td>{{ article.first_seen }}</td>
|
||||
<td><a href="{{ article.href }}">{{ article.title }}</a></td>
|
||||
<td>{% 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="num">{{ article.words }}</td>
|
||||
<td>{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted">never considered</span>{% endif %}</td>
|
||||
<td>{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
||||
<td class="num">{{ article.utility }}</td>
|
||||
<td class="num">{{ article.triage }}</td>
|
||||
<td class="num">{{ article.quality }}</td>
|
||||
<td class="num">{{ article.fit }}</td>
|
||||
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
||||
<td>{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
</section>{% endblock %}
|
||||
@@ -1 +1,40 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard"><h1>Overview</h1><p>The dashboard foundation is ready. Run and article views land in the next dashboard step.</p></section>{% endblock %}
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<h1>Overview</h1>
|
||||
<div class="cards">
|
||||
<section class="card"><h2>Last run</h2>
|
||||
{% if let Some(run) = last_run %}<p><a href="/dashboard/runs/{{ run.id }}">Run {{ run.id }}</a> · {{ run.date }} · <span class="badge {{ run.status }}">{{ run.status }}</span></p>
|
||||
<p class="muted">Started {{ run.started }} · {{ run.duration }}</p>
|
||||
<pre class="block">{% for line in run.lines %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
{% if run.warnings > 0 %}<p><a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }} warning{% if run.warnings != 1 %}s{% endif %}</a></p>{% endif %}
|
||||
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
{% else %}<p class="muted">No runs recorded yet.</p>{% endif %}
|
||||
</section>
|
||||
<section class="card"><h2>Budget today</h2>
|
||||
{% if budget.is_empty() %}<p class="muted">No providers configured.</p>{% else %}<dl class="kv budget">
|
||||
{% for line in budget %}<dt>{{ line.provider }}</dt><dd><meter value="{{ line.value }}" max="{{ line.max }}"{% if line.over %} class="over"{% endif %}></meter> {{ line.spent }} of {{ line.ceiling }}{% if line.over %} <strong class="error">ceiling reached</strong>{% endif %}</dd>
|
||||
{% endfor %}</dl>{% endif %}
|
||||
<p class="muted">Spend on the current UTC day across every recorded run.</p>
|
||||
</section>
|
||||
<section class="card"><h2>Ratings this week</h2>
|
||||
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the last seven days.</p>{% else %}<p>{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}: {% for count in ratings %}<span class="badge {{ count.label }}">{{ count.label }}</span> {{ count.count }}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
|
||||
<p><a href="/dashboard/ratings">Rating history</a></p>
|
||||
</section>
|
||||
<section class="card"><h2>Jobs</h2>
|
||||
{% if active_jobs.is_empty() && finished_jobs.is_empty() %}<p class="muted">No jobs recorded.</p>{% else %}
|
||||
{% if !active_jobs.is_empty() %}<ul class="jobs">{% for job in active_jobs %}<li><a href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted">requested {{ job.requested }}</span></li>{% endfor %}</ul>{% endif %}
|
||||
{% if !finished_jobs.is_empty() %}<p class="muted">Recently finished</p><ul class="jobs">{% for job in finished_jobs %}<li><a href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted">{{ job.finished }}</span>{% if let Some(message) = job.message %} <span class="muted">— {{ message }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
|
||||
{% endif %}
|
||||
<p><a href="/dashboard/jobs">All jobs</a></p>
|
||||
</section>
|
||||
{% if !config_warnings.is_empty() %}<section class="card"><h2>Config on disk</h2>
|
||||
<ul class="warnings">{% for line in config_warnings %}<li class="error">{{ line }}</li>{% endfor %}</ul>
|
||||
<p><a href="/dashboard/settings">Settings</a></p>
|
||||
</section>{% endif %}
|
||||
</div>
|
||||
<!-- step 6: sparklines (cost per run, selected per run, generation seconds; last 30 non-dry runs) -->
|
||||
<h2>Unrated picks</h2>
|
||||
{% if unrated.is_empty() %}<p class="muted">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted">Picks from the last three issues without a verdict yet.</p>
|
||||
<ul class="picks">{% for pick in unrated %}<li><a href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted">· {{ pick.feed }} · {{ pick.issue_date }}</span>
|
||||
{% let widget = pick.widget %}{% include "_rating_widget.html" %}</li>{% endfor %}</ul>{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<h1>Run {{ run.id }} · {{ run.date }} <span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted">dry run</span>{% endif %}</h1>
|
||||
<p class="muted">Started {{ run.started }} · finished {{ run.finished }} · {{ run.duration }} · {{ run.total_cost }}</p>
|
||||
<nav class="run-nav">{% if let Some(prev) = run.prev_id %}<a href="/dashboard/runs/{{ prev }}">← Run {{ prev }}</a>{% endif %}{% if let Some(href) = run.issue_href %}<a href="{{ href }}">Issue {{ run.date }}</a>{% else %}<span class="muted">No issue for {{ run.date }}</span>{% endif %}{% if let Some(next) = run.next_id %}<a href="/dashboard/runs/{{ next }}">Run {{ next }} →</a>{% endif %}</nav>
|
||||
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<h2>Funnel</h2>
|
||||
<div class="scroll-x"><table class="funnel-table">
|
||||
<thead><tr><th>stage</th><th>reached</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
||||
<tbody>{% for stage in funnel %}<tr>
|
||||
<td><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
||||
<td class="funnel-cell"><div class="funnel"><svg width="{{ stage.percent }}%" height="12" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
||||
<td class="num">{{ stage.reached }}</td>
|
||||
<td class="num">{{ stage.stopped }}</td>
|
||||
<td class="reasons">{% for reason in stage.reasons %}{{ reason.reason }} {{ reason.count }}{% if !loop.last %} · {% endif %}{% endfor %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>
|
||||
|
||||
{% if has_report %}<div class="cards">
|
||||
<section class="card"><h2>Admission mix</h2>{% if admission.is_empty() %}<p class="muted">Nothing admitted.</p>{% else %}<dl class="kv">{% for line in admission %}<dt>{{ line.name }}</dt><dd>{{ line.count }}</dd>{% endfor %}</dl>{% endif %}</section>
|
||||
<section class="card"><h2>Preference state</h2><dl class="kv">
|
||||
<dt>rated with embeddings</dt><dd>{{ preference.rated_with_embeddings }}</dd>
|
||||
<dt>knn gate</dt><dd>{{ preference.knn_gate }}</dd>
|
||||
<dt>feed gate</dt><dd>{{ preference.feed_gate }}</dd>
|
||||
<dt>verdicts in prompt</dt><dd>{{ preference.verdicts_in_prompt }}</dd>
|
||||
</dl></section>
|
||||
<section class="card"><h2>Timings</h2><div class="scroll-x"><table><thead><tr><th>stage</th><th class="num">seconds</th></tr></thead><tbody>{% for line in timings %}<tr><td>{{ line.stage }}</td><td class="num">{{ line.seconds }}</td></tr>{% endfor %}<tr><th>total</th><th class="num">{{ timings_total }}</th></tr></tbody></table></div></section>
|
||||
<section class="card"><h2>Provider usage</h2><div class="scroll-x"><table><thead><tr><th>provider</th><th class="num">in</th><th class="num">cached</th><th class="num">cache write</th><th class="num">out</th><th class="num">cost</th></tr></thead><tbody>{% for line in providers %}<tr><td>{{ line.provider }}</td><td class="num">{{ line.input }}</td><td class="num">{{ line.cached }}</td><td class="num">{{ line.cache_write }}</td><td class="num">{{ line.output }}</td><td class="num">{{ line.cost }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="card" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul>{% for warning in warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}</section>
|
||||
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div class="scroll-x"><table><thead><tr><th>feed</th><th class="num">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td>{{ line.name }}</td><td class="num">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</div>{% else %}<p class="muted">This run has no stored report; only the funnel and candidates are available.</p>{% endif %}
|
||||
|
||||
<h2>Config diff</h2>
|
||||
{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}<p class="muted">No settings changed since <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>{% else %}<p class="muted">Against the previous non-dry run, <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>
|
||||
<div class="scroll-x"><table class="diff"><thead><tr><th>key</th><th>before</th><th>after</th></tr></thead><tbody>{% for row in config_diff %}<tr><td><code>{{ row.key }}</code></td><td class="before">{{ row.before }}</td><td class="after">{{ row.after }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted">No earlier non-dry run to compare against.</p>{% endif %}
|
||||
|
||||
<h2>Near misses</h2>
|
||||
{% if near_misses.is_empty() %}<p class="muted">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
||||
<thead><tr><th>#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
||||
<tbody>{% for miss in near_misses %}<tr><td>{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td><a href="{{ miss.href }}">{{ miss.title }}</a></td><td>{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td>{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
|
||||
<h2>Candidates</h2>
|
||||
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
||||
<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>Admitted by <select name="admitted_by"><option value="">any</option>{% for name in retrievers %}<option value="{{ name }}"{% if let Some(current) = filters.admitted_by %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||
<label>Flag <select name="flag"><option value="">any</option><option value="exploration"{% if let Some(current) = filters.flag %}{% if current == "exploration" %} selected{% endif %}{% endif %}>exploration</option><option value="auto"{% if let Some(current) = filters.flag %}{% if current == "auto" %} selected{% endif %}{% endif %}>auto-include</option></select></label>
|
||||
<label>Title <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}"></label>
|
||||
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||
<button type="submit">Apply</button> <a href="/dashboard/runs/{{ run.id }}">Reset</a>
|
||||
</form>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
<div class="scroll-x"><table class="candidates" data-filter>
|
||||
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th><th>editor</th></tr></thead>
|
||||
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}</tbody></table></div>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
</section>{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<h1>Runs</h1>
|
||||
<form class="filters" method="get" action="/dashboard/runs">
|
||||
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
<div class="scroll-x"><table data-filter>
|
||||
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th>warnings</th></tr></thead>
|
||||
<tbody>{% for run in runs %}<tr>
|
||||
<td><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||
<td><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
||||
<td><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted">dry</span>{% endif %}</td>
|
||||
<td>{{ run.started }}</td>
|
||||
<td>{{ run.duration }}</td>
|
||||
<td>{{ run.funnel }}</td>
|
||||
<td>{{ run.costs }}</td>
|
||||
<td class="num">{{ run.total }}</td>
|
||||
<td class="num">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }}</a>{% else %}0{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
</section>{% endblock %}
|
||||
Reference in New Issue
Block a user