Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40. POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just ahead of robot 0.40. Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its words; /api/signals gives `items` beside `unread`, by the same rules as its list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -2552,6 +2552,7 @@ pub struct SignalRow {
|
||||
pub keywords: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub items: i64,
|
||||
pub unread: i64,
|
||||
}
|
||||
|
||||
@@ -2604,15 +2605,22 @@ impl Db {
|
||||
let mut a = Args::default();
|
||||
let user = a.p(user_id);
|
||||
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
||||
// The items it shows, and of those the unread: what its list counts, by the same rules.
|
||||
let hits = |unread: &str| {
|
||||
format!(
|
||||
"(SELECT count(*) FROM signal_verdicts v
|
||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
||||
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {})",
|
||||
not_hidden(&user)
|
||||
)
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT g.id, g.name, g.keywords, g.description, g.icon,
|
||||
(SELECT count(*) FROM signal_verdicts v
|
||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
||||
WHERE v.signal_id = g.id AND v.hit AND NOT coalesce(st.read, false) AND {}) AS unread
|
||||
"SELECT g.id, g.name, g.keywords, g.description, g.icon, {} AS items, {} AS unread
|
||||
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
|
||||
not_hidden(&user)
|
||||
hits("true"),
|
||||
hits("NOT coalesce(st.read, false)")
|
||||
);
|
||||
self.rows(&sql, a.0)
|
||||
.await?
|
||||
@@ -2624,6 +2632,7 @@ impl Db {
|
||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||
description: r.try_get("", "description")?,
|
||||
icon: r.try_get("", "icon")?,
|
||||
items: r.try_get("", "items")?,
|
||||
unread: r.try_get("", "unread")?,
|
||||
})
|
||||
})
|
||||
@@ -3403,11 +3412,12 @@ mod tests {
|
||||
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
||||
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
|
||||
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
|
||||
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| s.unread).collect::<Vec<_>>(), [1, 1]);
|
||||
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(), [(1, 1), (1, 1)]);
|
||||
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal");
|
||||
let f = db.signal(1, f1).await.unwrap().unwrap();
|
||||
assert_eq!((f.items, f.unread), (1, 0), "read in its feed is read in the Signal, and still one of its items");
|
||||
|
||||
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
||||
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
|
||||
|
||||
@@ -202,6 +202,52 @@ fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||
}
|
||||
|
||||
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||
/// server's page doing it, once a Signal.
|
||||
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
|
||||
async {
|
||||
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
|
||||
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||
if let Some(d) = description {
|
||||
state.push_str(&format!("\nIn their words: {d}"));
|
||||
}
|
||||
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||
serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": state,
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
/// Jev's choice, only if it is one it was offered.
|
||||
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// Show notes are HTML: Jev is given their words.
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
@@ -231,6 +277,20 @@ mod tests {
|
||||
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_is_one_jev_was_offered() {
|
||||
let offered = vec![("solid/robot".to_string(), "Robot: ai, bot".to_string()), ("solid/laptop-code".to_string(), "Laptop Code".to_string())];
|
||||
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||
let item = |n: usize| Unjudged {
|
||||
|
||||
@@ -48,6 +48,7 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/feeds/{id}/entries", get(entries))
|
||||
.route("/api/entries", get(all_entries))
|
||||
.route("/api/signals", get(list_signals).post(add_signal))
|
||||
.route("/api/signals/icon", post(pick_signal_icon))
|
||||
.route("/api/signals/{id}", patch(patch_signal).delete(remove_signal))
|
||||
.route("/api/signals/{id}/entries", get(signal_entries))
|
||||
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
||||
@@ -499,6 +500,49 @@ async fn add_signal(
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IconAsk {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
description: Option<String>,
|
||||
/// `[style/name, label]` for each icon the page found for the Signal's words.
|
||||
candidates: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
|
||||
/// or 404 when the server has no key.
|
||||
async fn pick_signal_icon(
|
||||
State(state): State<WebState>,
|
||||
_user: crate::db::User,
|
||||
Json(body): Json<IconAsk>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let Ok(key) = std::env::var("TYPESAFE_KEY") else {
|
||||
return Err(ApiError::not_found("this server has no TypeSafe key to pick icons with"));
|
||||
};
|
||||
let name = signal_name(&body.name)?;
|
||||
let words = clean_words(body.keywords)?;
|
||||
let description = signal_description(body.description)?;
|
||||
// Each one is in the question, and comes back as the Signal's icon: only real icon names.
|
||||
let mut candidates: Vec<(String, String)> = Vec::new();
|
||||
for (id, label) in body.candidates {
|
||||
let id = signal_icon(Some(id))?.ok_or_else(|| ApiError::bad_request("an empty icon"))?;
|
||||
if !candidates.iter().any(|(c, _)| *c == id) {
|
||||
candidates.push((id, label.chars().take(200).collect()));
|
||||
}
|
||||
}
|
||||
if candidates.is_empty() || candidates.len() > 60 {
|
||||
return Err(ApiError::bad_request("between 1 and 60 icons to choose from"));
|
||||
}
|
||||
let icon = crate::signals::pick_icon(&state.ctx, &key, &name, &words, description.as_deref(), &candidates)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Jev could not pick an icon");
|
||||
ApiError::bad_gateway("Jev could not pick an icon just now")
|
||||
})?;
|
||||
Ok(Json(serde_json::json!({ "icon": icon })))
|
||||
}
|
||||
|
||||
async fn patch_signal(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
@@ -1203,6 +1247,14 @@ impl ApiError {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
/// A service ipx asks on someone's behalf failed them.
|
||||
fn bad_gateway(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error: anyhow::Error::msg(msg.into()),
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
|
||||
Reference in New Issue
Block a user