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:
2026-10-16 19:09:26 +00:00
parent d0ed953e84
commit 2ed8781604
9 changed files with 231 additions and 17 deletions

View File

@@ -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

View File

@@ -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());

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -19,6 +19,9 @@ their Signals that has not heard about the item yet.
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
**Let Jev pick** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with a + of its own.
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
Its header says what it listens for (the sentence, or the words), and that nothing is picked
@@ -172,13 +175,14 @@ does not exist.
```json
[
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.", "unread": 5 },
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "unread": 0 }
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
]
```
`unread` counts the items Jev picked out that are still in a subscribed feed, not hidden, and
not read.
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
its list shows; `unread` those of them not read.
### `POST /api/signals`
@@ -238,6 +242,46 @@ A string saves the key and lets the judge try it again. `null` or an empty strin
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
more.
### `POST /api/signals/icon`
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
```json
{
"name": "AI Development",
"keywords": ["ai", "development", "programming"],
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
}
```
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
```json
{
"model": "jev-latest",
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"questions": { "icon": {
"type": "choice",
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
} }
}
```
TypeSafe answered, on 2026-10-16, for six candidates:
```json
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
```
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
### The event
Once a pass, for each person it found items for, the judge sends this on the event stream

View File

@@ -182,3 +182,13 @@ test('a Signal draws its icon only when it is a style and a plain name', () => {
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
});
test('Jev is offered the icons found for each word of a Signal', () => {
const all = JSON.stringify([
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
]);
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
});

View File

@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261).
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261), or Jev's pick of one, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
### Changed

View File

@@ -60,7 +60,7 @@ function signalFolder(list: HTMLElement, q: string){
if(!open) return;
for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child',
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
@@ -97,7 +97,7 @@ function renderSignals(){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
el.onclick=()=>selectFeed(':signal:'+sg.id);
list.appendChild(el);
}
@@ -141,6 +141,7 @@ function signalModal(sg?: Signal){
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div id="sgpicker" hidden>
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
<button type="button" class="dlink" id="sgjev">Let Jev pick</button>
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
</div></div>
@@ -179,6 +180,16 @@ function signalModal(sg?: Signal){
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
};
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.textContent='Asking Jev…';
try{
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
$<HTMLInputElement>('#sgdesc').value.trim()||null);
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
else toast('No icon matches those words');
}catch(e){ toast(why(e),true); }
finally{ b.disabled=false; b.textContent='Let Jev pick'; }
};
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
@@ -191,6 +202,10 @@ function signalModal(sg?: Signal){
try{
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body});
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
if(!sg&&!icon) jevIcon(name,keywords,description)
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
.catch(()=>{});
closeModal(); toast(sg?'Saved. Jev reads everything again':'Listening. What Jev picks out shows up here');
// Open, so the new one is there to see.
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
@@ -233,3 +248,24 @@ function iconMatches(all: [string,string,string][], q: string, max: number){
};
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
}
/// "12 items · 3 unread", as a feed's row counts its own.
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
const out=new Map<string,string>();
for(const w of [...new Set(words)])
for(const [id,label,terms] of iconMatches(all,w,6)) if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
return [...out];
}
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
const candidates=iconCandidates(await iconList(),name,keywords,description);
if(!candidates.length) return null;
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
return r.icon;
}

View File

@@ -95,6 +95,8 @@ interface Signal {
description: string | null;
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
icon: string | null;
/// The items it holds, read or not, and of those the unread.
items: number;
unread: number;
}