Show any-article nearest neighbours and widen the format/kind vocabularies

The article dashboard page gains a "Nearest articles (any)" table: the
ten closest stored embeddings by cosine, regardless of rating or run,
via a brute-force scan of article_embeddings. The deep-assessment format
facet grows from 5 to 14 values (code_repository, documentation_reference,
tool_or_product_page, discussion_thread, paper_or_report,
interview_or_transcript, video_or_podcast, fiction_or_humor, other) and
the triage kind from 10 to 16 (repo, docs, discussion, paper, media,
fiction), so a GitHub repository is no longer forced into analysis_essay.
Both prompt versions bump to 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
2026-09-06 17:52:37 +00:00
co-authored by Claude Fable 5.1
parent 142a8d9905
commit f0529d2d01
8 changed files with 283 additions and 18 deletions
+161 -10
View File
@@ -4,7 +4,10 @@
//! `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>`.
//! order of §9.3, compares its embedding with every compatible cached article,
//! and wraps `telemetry::render_explain` verbatim in `<pre>`.
use std::collections::HashMap;
use askama::Template;
use axum::Router;
@@ -22,6 +25,7 @@ use super::{
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;
@@ -32,6 +36,8 @@ 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<AppState> {
@@ -395,6 +401,23 @@ pub struct EmbeddingView {
pub input_hash: String,
}
#[derive(Debug, Clone)]
struct StoredEmbedding {
view: EmbeddingView,
vector: Vec<f32>,
}
#[derive(Debug, Clone)]
struct NearestArticleView {
id: ArticleId,
cosine: String,
title: String,
feed: String,
first_seen: String,
rating: Option<String>,
rating_class: &'static str,
}
#[derive(Debug, Clone)]
pub struct RatingEventView {
pub id: i64,
@@ -460,6 +483,7 @@ struct ArticleTemplate {
assessments: Vec<AssessmentView>,
history: Vec<HistoryRow>,
latest_signals: Option<SignalsView>,
nearest_articles: Option<Vec<NearestArticleView>>,
embedding: Option<EmbeddingView>,
events: Vec<RatingEventView>,
}
@@ -611,22 +635,73 @@ async fn embedding(
db: &Db,
article_id: ArticleId,
config: &Config,
) -> Result<Option<EmbeddingView>, sqlx::Error> {
) -> anyhow::Result<Option<StoredEmbedding>> {
let row = sqlx::query(
"SELECT model, dimension, created_at, input_hash FROM article_embeddings
"SELECT model, dimension, created_at, input_hash, embedding 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"),
let Some(row) = row else { return Ok(None) };
let dimension: i64 = row.get("dimension");
let decoded_dimension = usize::try_from(dimension)
.map_err(|_| anyhow::anyhow!("invalid embedding dimension {dimension}"))?;
let vector = decode_blob(&row.get::<Vec<u8>, _>("embedding"), decoded_dimension)?;
Ok(Some(StoredEmbedding {
view: EmbeddingView {
model: row.get("model"),
dimension,
created_at: fmt_stored_time(Some(&row.get::<String, _>("created_at")), config),
input_hash: row.get("input_hash"),
},
vector,
}))
}
async fn nearest_article_views(
db: &Db,
article_id: ArticleId,
stored: &StoredEmbedding,
config: &Config,
) -> anyhow::Result<Vec<NearestArticleView>> {
let dimension = usize::try_from(stored.view.dimension)
.map_err(|_| anyhow::anyhow!("invalid embedding dimension {}", stored.view.dimension))?;
let scored = embedding::nearest_articles(
db,
article_id,
&stored.view.model,
dimension,
&stored.vector,
NEAREST_ARTICLES,
)
.await?;
let ids = scored.iter().map(|(id, _)| *id).collect::<Vec<_>>();
let articles = db.get_articles(&ids).await?;
let ratings = db
.current_ratings(CURRENT_RATING_LOOKBACK_DAYS)
.await?
.into_iter()
.map(|rating| (rating.article_id, rating.label))
.collect::<HashMap<_, _>>();
Ok(scored
.into_iter()
.filter_map(|(id, cosine)| {
let article = articles.get(&id)?;
let rating = ratings.get(&id).cloned();
Some(NearestArticleView {
id,
cosine: format!("{cosine:.3}"),
title: article.title.clone(),
feed: article.feed_title.clone(),
first_seen: crate::web::format_time(article.first_seen, config),
rating_class: widget_label(rating.as_deref()),
rating,
})
})
.collect())
}
async fn detail(
State(state): State<AppState>,
auth: AuthSession,
@@ -693,7 +768,17 @@ async fn detail(
.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 embedding = embedding(db, id, &config)
.await
.map_err(WebError::Internal)?;
let nearest_articles = match embedding.as_ref() {
Some(stored) => Some(
nearest_article_views(db, id, stored, &config)
.await
.map_err(WebError::Internal)?,
),
None => None,
};
let mut page = Page::new(article.title.clone(), viewer, "articles");
page.flash = take_flash(&session).await?;
@@ -742,7 +827,8 @@ async fn detail(
assessments,
history,
latest_signals,
embedding,
nearest_articles,
embedding: embedding.map(|stored| stored.view),
events,
})
.into_response())
@@ -751,6 +837,7 @@ async fn detail(
#[cfg(test)]
mod tests {
use super::*;
use crate::curate::embedding::encode_blob;
use crate::web::dashboard::tests::{
app_with_users, assert_admin_only, get, login_cookie, seed,
};
@@ -856,6 +943,40 @@ mod tests {
async fn article_detail_shows_assessments_run_history_and_rating_events() {
let seed = seed().await;
let config = Config::default();
for (article_id, vector) in [
(1, [1.0_f32, 0.0_f32]),
(2, [0.8_f32, 0.6_f32]),
(3, [0.6_f32, 0.8_f32]),
] {
sqlx::query(
"INSERT INTO article_embeddings
(article_id, model, dimension, input_hash, embedding, created_at)
VALUES (?, 'voyage-4-lite', 2, ?, ?, '2026-09-02T05:30:30Z')
ON CONFLICT(article_id) DO UPDATE SET
model = excluded.model, dimension = excluded.dimension,
input_hash = excluded.input_hash, embedding = excluded.embedding,
created_at = excluded.created_at",
)
.bind(article_id)
.bind(if article_id == 1 {
"abc123"
} else {
"nearest-hash"
})
.bind(encode_blob(&vector).unwrap())
.execute(seed.db.pool())
.await
.unwrap();
}
sqlx::query(
"INSERT INTO rating_events
(article_id, kind, source, label, value, event_at)
VALUES (2, 'explicit', 'cli', 'good', 0.35, '2026-09-02T11:00:00Z')",
)
.execute(seed.db.pool())
.await
.unwrap();
let views = assessments(&seed.db, 1, &config).await.unwrap();
assert_eq!(views.len(), 2);
assert_eq!(views[0].stage, "triage");
@@ -885,6 +1006,14 @@ mod tests {
let list = assert_admin_only(&app, "/dashboard/articles").await;
assert!(list.contains("Article 1 about prose"), "{list}");
assert!(list.contains("/dashboard/articles/1"), "{list}");
assert!(
list.contains("<option value=\"repo\">repo</option>"),
"{list}"
);
assert!(
list.contains("<option value=\"fiction\">fiction</option>"),
"{list}"
);
let body = assert_admin_only(&app, "/dashboard/articles/1").await;
assert!(body.contains("Careful and first-hand"), "{body}");
@@ -910,6 +1039,28 @@ mod tests {
);
assert!(body.contains("Top Stories"), "{body}");
assert!(body.contains("Alpha Blog"), "{body}");
let nearest = body
.split_once("<h2>Nearest articles (any)</h2>")
.expect("nearest heading")
.1
.split_once("<h2>Embedding</h2>")
.expect("embedding heading")
.0;
let second = nearest
.find("href=\"/dashboard/articles/2\">Article 2 about graphs")
.expect("nearest article 2");
let third = nearest
.find("href=\"/dashboard/articles/3\">Article 3 about prose")
.expect("nearest article 3");
assert!(second < third, "higher cosine must render first: {nearest}");
assert!(nearest.contains(">0.800</td>"), "{nearest}");
assert!(nearest.contains(">0.600</td>"), "{nearest}");
assert!(nearest.contains("badge good\">good"), "{nearest}");
assert!(nearest.contains(">unrated</span>"), "{nearest}");
assert!(
!nearest.contains("href=\"/dashboard/articles/1\""),
"the article itself must be excluded: {nearest}"
);
let rejected = assert_admin_only(&app, "/dashboard/articles/3").await;
assert!(rejected.contains("rejected by provider"), "{rejected}");