Signals: index the judge's query, six calls at once, one call per item on the server's key, tokens counted, a week's look-back an admin sets, edits that ask again only what was picked out (#274, #275, #276)

The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.

An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:13:25 +00:00
parent 026fbd1622
commit 2077ea5810
16 changed files with 293 additions and 106 deletions

View File

@@ -111,6 +111,7 @@ from 2026-10-11 15:15 UTC. All gauges:
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
| `ipx_signals_judged_total`, `ipx_signals_hits_total` | counters: items Jev judged for Signals, and Signals they fit (#254) |
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"error"}`, `ipx_jev_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended, and the seconds waited; `malformed` is ipx's request at fault (#257) |
| `ipx_jev_tokens_total{kind="input"\|"output"}` | counter: tokens TypeSafe counted, as its answers say: what Signals cost (#275) |
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |

View File

@@ -14,12 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended and the time they took, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270).
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275).
- An admin setting for how many days back a new Signal reads (#276).
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
### Changed
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
- Signals fill faster and cost less: six calls to TypeSafe at once instead of one, everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
### Fixed
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
## [1.3.0] - 2026-10-16

View File

@@ -53,6 +53,9 @@ pub struct General {
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
/// on each person's own (#271). Without that key it changes nothing.
pub signals_on_server: bool,
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
/// month of production's feeds was about 8,000.
pub signal_days: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -211,6 +214,7 @@ impl Default for General {
directory_token: String::new(),
directory_share: false,
signals_on_server: false,
signal_days: 7,
}
}
}
@@ -337,6 +341,12 @@ pub struct Stored {
pub directory_share: bool,
#[serde(default)]
pub signals_on_server: bool,
#[serde(default = "signal_days")]
pub signal_days: u64,
}
fn signal_days() -> u64 {
General::default().signal_days
}
fn art_cache_mb() -> u64 {
@@ -344,7 +354,7 @@ fn art_cache_mb() -> u64 {
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 10] = [
const STORED_KEYS: [&str; 11] = [
"schedule",
"max_total_gb",
"max_age_days",
@@ -355,6 +365,7 @@ const STORED_KEYS: [&str; 10] = [
"directory_token",
"directory_share",
"signals_on_server",
"signal_days",
];
impl Stored {
@@ -371,6 +382,7 @@ impl Stored {
directory_token: g.directory_token.clone(),
directory_share: g.directory_share,
signals_on_server: g.signals_on_server,
signal_days: g.signal_days,
}
}
@@ -386,6 +398,7 @@ impl Stored {
g.directory_token = self.directory_token;
g.directory_share = self.directory_share;
g.signals_on_server = self.signals_on_server;
g.signal_days = self.signal_days;
}
}

View File

@@ -79,6 +79,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
// and visits the table only for the rows it shows. Sorting All Subscriptions from the
// table read all 13,700 of its rows, show notes and all, half a second to two cold (#188).
"CREATE INDEX IF NOT EXISTS entries_by_time ON entries (feed_id, guid, published, first_seen)",
// The Signals judge's question on every wake, what is recent in each subscribed feed: as
// the expression it is asked by. Without it, each wake read all of entries, 605 MB, 566 ms
// to find nothing (#274).
"CREATE INDEX IF NOT EXISTS entries_recent ON entries (feed_id, (coalesce(published, first_seen)))",
] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
@@ -113,6 +117,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
if orm.execute_unprepared("SELECT recheck FROM signal_verdicts LIMIT 0").await.is_err() {
let sql = "ALTER TABLE signal_verdicts ADD COLUMN recheck BOOLEAN NOT NULL DEFAULT false";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
if orm.execute_unprepared("SELECT icon FROM signals LIMIT 0").await.is_err() {
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
@@ -2589,8 +2597,6 @@ pub struct Unjudged {
pub signals: Vec<Ask>,
}
/// How far back a Signal looks when it is made: a month of items, not every back catalogue.
pub const SIGNAL_DAYS: i64 = 30;
impl Db {
pub async fn signals(&self, user_id: i64) -> Result<Vec<SignalRow>> {
@@ -2654,9 +2660,10 @@ impl Db {
Ok(m.id)
}
/// Changes a Signal. A new name, words or sentence puts every item to Jev again, since what it
/// said was about the old question (#259); a new icon does not. False for a Signal that is not
/// this person's.
/// Changes a Signal. A new name, words or sentence puts what it picked out to Jev again, since
/// that was the old question's answer; what it passed over stays passed over, and new items
/// are asked the new question as they come (#276). A new icon asks nothing. False for a Signal
/// that is not this person's.
pub async fn set_signal(&self, user_id: i64, id: i64, change: SignalChange<'_>) -> Result<bool> {
let SignalChange { name, words, description, icon } = change;
if self.signal(user_id, id).await?.is_none() {
@@ -2676,7 +2683,12 @@ impl Db {
m.icon = Set(i.map(str::to_owned));
}
if name.is_some() || words.is_some() || description.is_some() {
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
signal_verdicts::Entity::update_many()
.col_expr(signal_verdicts::Column::Recheck, Expr::val(true).into())
.filter(signal_verdicts::Column::SignalId.eq(id))
.filter(signal_verdicts::Column::Hit.eq(true))
.exec(&self.orm)
.await?;
}
if name.is_some() || words.is_some() || description.is_some() || icon.is_some() {
m.update(&self.orm).await?;
@@ -2703,13 +2715,14 @@ impl Db {
.await
}
/// Up to `limit` items, newest first, that someone with a key has a Signal not yet asked
/// about: from the feeds they subscribe to, the last `SIGNAL_DAYS`, and not hidden from them.
/// `skip` are people whose key TypeSafe has refused. With `everyone`, the server's key is
/// used, and people without a key of their own are asked for too (#271).
pub async fn unjudged(&self, skip: &[i64], limit: i64, everyone: bool) -> Result<Vec<Unjudged>> {
/// Up to `limit` items, newest first, that someone with a key has a Signal to ask about: not
/// yet asked, from the last `days` (#276), or picked out before the Signal changed, however
/// old; from the feeds they subscribe to, and not hidden from them. `skip` are people whose
/// key TypeSafe has refused. With `everyone`, the server's key is used, and people without a
/// key of their own are asked for too (#271).
pub async fn unjudged(&self, skip: &[i64], limit: i64, everyone: bool, days: u64) -> Result<Vec<Unjudged>> {
let mut a = Args::default();
let since = a.p(now() - SIGNAL_DAYS * 86_400);
let since = a.p(now() - days as i64 * 86_400);
let skip = if skip.is_empty() {
String::new()
} else {
@@ -2717,23 +2730,32 @@ impl Db {
};
let limit = a.p(limit);
let keyed = if everyone { "true" } else { "u.typesafe_key IS NOT NULL" };
let sql = format!(
"SELECT g.id AS signal_id, g.name, g.keywords, g.description AS signal_description, g.user_id, u.typesafe_key, e.feed_id, e.guid, e.title,
e.description, f.title AS feed_title
FROM signals g
JOIN users u ON u.id = g.user_id
JOIN subscriptions s ON s.user_id = g.user_id
JOIN entries e ON e.feed_id = s.feed_id
LEFT JOIN feeds f ON f.id = e.feed_id
WHERE {keyed} {skip}
AND coalesce(e.published, e.first_seen) >= {since}
AND NOT EXISTS (SELECT 1 FROM signal_verdicts v
WHERE v.signal_id = g.id AND v.feed_id = e.feed_id AND v.guid = e.guid)
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = g.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)
ORDER BY coalesce(e.published, e.first_seen) DESC, e.feed_id, e.guid, g.user_id
LIMIT {limit}"
// Two halves, not one OR: the recent half reads entries_recent, and the rechecks are a few
// rows of signal_verdicts. The same placeholders serve both.
let half = |items: &str| {
format!(
"SELECT g.id AS signal_id, g.name, g.keywords, g.description AS signal_description, g.user_id, u.typesafe_key,
e.feed_id, e.guid, e.title, e.description, f.title AS feed_title, coalesce(e.published, e.first_seen) AS at
FROM signals g
JOIN users u ON u.id = g.user_id
JOIN subscriptions s ON s.user_id = g.user_id
{items}
LEFT JOIN feeds f ON f.id = e.feed_id
WHERE {keyed} {skip}
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = g.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)"
)
};
let recent = half(&format!(
"JOIN entries e ON e.feed_id = s.feed_id AND coalesce(e.published, e.first_seen) >= {since}
AND NOT EXISTS (SELECT 1 FROM signal_verdicts v
WHERE v.signal_id = g.id AND v.feed_id = e.feed_id AND v.guid = e.guid)"
));
let again = half(
"JOIN signal_verdicts v ON v.signal_id = g.id AND v.feed_id = s.feed_id AND v.recheck
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid",
);
let sql = format!("SELECT * FROM ({recent} UNION ALL {again}) q ORDER BY at DESC, feed_id, guid, user_id LIMIT {limit}");
let mut out: Vec<Unjudged> = Vec::new();
for r in self.rows(&sql, a.0).await? {
let (user_id, feed_id, guid): (i64, String, String) =
@@ -2769,9 +2791,9 @@ impl Db {
// would refuse and SQLite would keep for nothing.
// shortcut: keywords changed mid-call keep a verdict on the old ones for that item.
self.exec(
"INSERT INTO signal_verdicts (signal_id, feed_id, guid, hit)
SELECT $1, $2, $3, $4 WHERE EXISTS (SELECT 1 FROM signals WHERE id = $1)
ON CONFLICT (signal_id, feed_id, guid) DO UPDATE SET hit = excluded.hit",
"INSERT INTO signal_verdicts (signal_id, feed_id, guid, hit, recheck)
SELECT $1, $2, $3, $4, false WHERE EXISTS (SELECT 1 FROM signals WHERE id = $1)
ON CONFLICT (signal_id, feed_id, guid) DO UPDATE SET hit = excluded.hit, recheck = false",
vec![(*signal).into(), feed_id.into(), guid.into(), (*hit).into()],
)
.await?;
@@ -3397,23 +3419,23 @@ mod tests {
let food = db.add_signal(1, "Food", &words(&["baking"]), Some("Bread and cakes")).await.unwrap();
db.add_signal(2, "Theirs", &words(&["x"]), None).await.unwrap();
assert!(db.unjudged(&[], 50, false).await.unwrap().is_empty(), "nobody has a key yet");
let everyone = db.unjudged(&[], 50, true).await.unwrap();
assert!(db.unjudged(&[], 50, false, 30).await.unwrap().is_empty(), "nobody has a key yet");
let everyone = db.unjudged(&[], 50, true, 30).await.unwrap();
let mut seen: Vec<_> = everyone.iter().map(|u| (u.user_id, u.guid.as_str(), u.key.is_none())).collect();
seen.sort();
assert_eq!(seen, [(1, "x", true), (1, "y", true), (2, "x", true)], "on the server's key, everyone's Signals, no key of their own needed");
db.set_typesafe_key(1, Some("k")).await.unwrap();
let queue = db.unjudged(&[], 50, false).await.unwrap();
let queue = db.unjudged(&[], 50, false, 30).await.unwrap();
let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect();
seen.sort();
assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out");
let ask = queue[0].signals.iter().find(|a| a.id == food).unwrap();
assert_eq!((ask.name.as_str(), ask.description.as_deref()), ("Food", Some("Bread and cakes")), "Jev is asked about the name and the sentence");
assert!(db.unjudged(&[1], 50, false).await.unwrap().is_empty(), "a refused key is passed over");
assert!(db.unjudged(&[1], 50, false, 30).await.unwrap().is_empty(), "a refused key is passed over");
db.record_verdicts("f", "x", &[(f1, true), (food, false)]).await.unwrap();
db.record_verdicts("g", "y", &[(f1, false), (food, true)]).await.unwrap();
assert!(db.unjudged(&[], 50, false).await.unwrap().is_empty(), "nothing is asked twice");
assert!(db.unjudged(&[], 50, false, 30).await.unwrap().is_empty(), "nothing is asked twice");
let order = order_sql("published", "desc");
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
@@ -3430,15 +3452,25 @@ mod tests {
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
assert!(!db.remove_signal(2, f1).await.unwrap());
db.set_signal(1, f1, SignalChange { words: Some(&words(&["f1"])), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh");
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
// An edit asks again only what was picked out, however old, and leaves what was passed
// over (#276). Until answered, what was picked out stays in the list.
db.record_verdicts("g", "old", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence");
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
let again: Vec<_> = db.unjudged(&[], 50, false, 30).await.unwrap().iter()
.map(|u| (u.guid.clone(), u.signals.iter().map(|a| a.id).collect::<Vec<_>>())).collect();
assert_eq!(again, [("x".to_string(), vec![f1]), ("old".to_string(), vec![f1])], "y was passed over, and stays so");
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 2);
db.record_verdicts("f", "x", &[(f1, false)]).await.unwrap();
db.record_verdicts("g", "old", &[(f1, true)]).await.unwrap();
assert!(db.unjudged(&[], 50, false, 30).await.unwrap().is_empty(), "answered, not asked again");
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1);
assert!(db.unjudged(&[], 50, false, 1).await.unwrap().is_empty());
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1, "a new icon asks nothing again");
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 2, "x and old, kept");
assert!(db.unjudged(&[], 50, false, 30).await.unwrap().is_empty(), "a new icon asks nothing again");
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
assert!(db.remove_signal(1, food).await.unwrap());
db.record_verdicts("f", "x", &[(food, true)]).await.unwrap();

View File

@@ -333,6 +333,10 @@ pub mod signal_verdicts {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
pub hit: bool,
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
/// Signal's list until Jev has answered the new question.
#[sea_orm(default_value = false)]
pub recheck: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -48,6 +48,8 @@ pub struct Stats {
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
pub jev_calls: [AtomicU64; 4],
pub jev_micros: AtomicU64,
/// Tokens TypeSafe counted, input and output, as its answers say.
pub jev_tokens: [AtomicU64; 2],
/// The worker's job now, and since when.
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
/// Jobs finished, by kind: how many, and their seconds all told.
@@ -77,6 +79,7 @@ impl Stats {
signals_last_pass: AtomicI64::new(0),
jev_calls: [const { AtomicU64::new(0) }; 4],
jev_micros: AtomicU64::new(0),
jev_tokens: [const { AtomicU64::new(0) }; 2],
job: std::sync::Mutex::new(None),
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
}
@@ -250,6 +253,8 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
let calls: Vec<(&str, f64)> = labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
counter(out, "ipx_jev_requests_total", "Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.", &calls);
counter(out, "ipx_jev_seconds_total", "Seconds spent waiting on Jev.", &[("", st.jev_micros.load(Relaxed) as f64 / 1e6)]);
counter(out, "ipx_jev_tokens_total", "Tokens TypeSafe counted for calls to Jev, what they cost.",
&[("kind=\"input\"", st.jev_tokens[0].load(Relaxed) as f64), ("kind=\"output\"", st.jev_tokens[1].load(Relaxed) as f64)]);
for (name, help, v) in [
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),

View File

@@ -5,6 +5,9 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use futures_util::StreamExt as _;
use anyhow::Result;
use tracing::Instrument;
@@ -21,10 +24,15 @@ use crate::metrics::STATS;
const BATCH: i64 = 50;
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
/// of items took twenty minutes; few enough at once to stay a polite client of TypeSafe.
const IN_FLIGHT: usize = 6;
/// How sure Jev has to be that an item is about the keywords.
const THRESHOLD: f64 = 0.5;
/// Of an item's text, which is a podcast's whole show notes at times.
const TEXT_CHARS: usize = 2000;
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
const TEXT_CHARS: usize = 1000;
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
loop {
@@ -66,56 +74,78 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
let server = server_key(ctx);
// People's refused keys are not what is asked with when the server's is.
let skip: Vec<i64> = if server.is_some() { Vec::new() } else { ctx.signals_refused.lock().unwrap().iter().copied().collect() };
let items = ctx.db.unjudged(&skip, BATCH, server.is_some()).await?;
let days = ctx.cfg().general.signal_days;
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
if items.is_empty() {
return Ok(false);
}
let span = tracing::info_span!("signals", entries = items.len(), judged = tracing::field::Empty, hits = tracing::field::Empty);
let span = tracing::info_span!("signals", entries = items.len(), calls = tracing::field::Empty, judged = tracing::field::Empty, hits = tracing::field::Empty);
async {
let mut refused = HashSet::new();
let groups = calls(&items, server.is_some());
tracing::Span::current().record("calls", groups.len());
let refused = std::sync::Mutex::new(HashSet::new());
let stopped = AtomicBool::new(false);
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
let mut stopped = false;
let mut judged = 0;
for item in &items {
if refused.contains(&item.user_id) {
continue;
}
let Some(key) = server.as_deref().or(item.key.as_deref()) else { continue };
match judge(ctx, item, key).await {
Ok(verdicts) => {
judged += 1;
let hits = verdicts.iter().filter(|(_, h)| *h).count();
STATS.signals_judged.fetch_add(1, Relaxed);
STATS.signals_hits.fetch_add(hits as u64, Relaxed);
if hits > 0 {
*hits_for.entry(item.user_id).or_default() += hits;
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
// Each call starts only when a place is free, so one that sees a key refused or the pass
// stopped is not sent; those already in flight are let finish, and their answers kept.
// By index: a closure taking a reference here is not Send for every lifetime, which
// tokio::spawn asks of the judge's future.
let mut answers = futures_util::stream::iter(0..groups.len())
.map(|n| {
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
async move {
let first = group[0];
if stopped.load(Relaxed) || refused.lock().unwrap().contains(&first.user_id) {
return None;
}
let key = server.as_deref().or(first.key.as_deref())?;
Some((group, judge(ctx, group, key).await))
}
})
.buffer_unordered(IN_FLIGHT);
while let Some(answer) = answers.next().await {
let Some((group, answer)) = answer else { continue };
let first = group[0];
match answer {
Ok(verdicts) => {
for (item, verdicts) in group.iter().zip(verdicts) {
judged += 1;
let hits = verdicts.iter().filter(|(_, h)| *h).count();
STATS.signals_judged.fetch_add(1, Relaxed);
STATS.signals_hits.fetch_add(hits as u64, Relaxed);
if hits > 0 {
*hits_for.entry(item.user_id).or_default() += hits;
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
}
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(step("record_verdicts")).await?;
}
}
// The server's key refused is everyone's: the queue waits for the next pass.
Err(e) if refuses_key(&e) && server.is_some() => {
if !stopped.swap(true, Relaxed) {
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
}
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(step("record_verdicts")).await?;
}
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
// The server's key refused is everyone's: the queue waits for the next pass.
Err(e) if refuses_key(&e) && server.is_some() => {
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
stopped = true;
break;
}
Err(e) if refuses_key(&e) => {
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
refused.insert(item.user_id);
if refused.lock().unwrap().insert(first.user_id) {
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
}
}
// TypeSafe says the request itself is wrong, so every other item's would be too:
// the rest wait for the next pass rather than each failing the same way (#257).
Err(e) if malformed(&e) => {
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
stopped = true;
break;
if !stopped.swap(true, Relaxed) {
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
}
}
// Left unjudged, so it is asked again next pass.
Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
}
}
drop(answers);
let span = tracing::Span::current();
span.record("judged", judged);
span.record("hits", hits_for.values().sum::<usize>());
@@ -123,16 +153,31 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
for (user, hits) in hits_for {
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
}
let refused = refused.into_inner().unwrap();
let some_refused = !refused.is_empty();
ctx.signals_refused.lock().unwrap().extend(refused);
// A pass that only failed would otherwise go straight round again on the same items, and
// one TypeSafe refused as malformed would ask again at once, however full.
anyhow::Ok(!stopped && (full(&items) || some_refused))
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
}
.instrument(span)
.await
}
/// The calls a batch makes, each one item and the people asking about it. On the server's key
/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys
/// each pays for their own. `unjudged` puts one item's rows together.
fn calls(items: &[Unjudged], share: bool) -> Vec<Vec<&Unjudged>> {
let mut out: Vec<Vec<&Unjudged>> = Vec::new();
for item in items {
match out.last_mut() {
Some(g) if share && (g[0].feed_id.as_str(), g[0].guid.as_str()) == (item.feed_id.as_str(), item.guid.as_str()) => g.push(item),
_ => out.push(vec![item]),
}
}
out
}
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
/// alone said only 422 (#257).
#[derive(Debug)]
@@ -168,23 +213,28 @@ fn malformed(e: &anyhow::Error) -> bool {
matches!(status(e), Some(400 | 422))
}
async fn judge(ctx: &Ctx, item: &Unjudged, key: &str) -> Result<Vec<(i64, bool)>> {
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str) -> Result<Vec<Vec<(i64, bool)>>> {
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
let span = tracing::info_span!(
"jev",
user = item.user_id,
signal_count = item.signals.len(),
user = group[0].user_id,
people = group.len(),
signal_count = asks.len(),
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
);
let answer = called(span, ask(ctx, key, &request(item))).await?;
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
// A question Jev left unanswered stays unjudged and is asked again.
Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect())
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
}
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
fn request(item: &Unjudged) -> serde_json::Value {
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
let state = format!(
"Feed: {}\nTitle: {}\n\n{text}",
@@ -192,7 +242,7 @@ fn request(item: &Unjudged) -> serde_json::Value {
item.title.as_deref().unwrap_or("")
);
let questions: serde_json::Map<String, serde_json::Value> =
item.signals.iter().map(|a| (key(a.id), question(a))).collect();
asks.iter().map(|a| (key(a.id), question(a))).collect();
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
}
@@ -236,6 +286,8 @@ pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], descr
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
);
called(span, async {
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
@@ -252,6 +304,8 @@ pub async fn check_key(ctx: &Ctx, key: &str) -> Result<()> {
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
);
let body = serde_json::json!({
"model": "jev-latest",
@@ -277,7 +331,15 @@ async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_jso
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
return Err(Refused { status, body }.into());
}
Ok(r.json().await?)
let answer: serde_json::Value = r.json().await?;
// What a call cost, which TypeSafe says and nothing else would (#275).
for (n, kind) in ["input_tokens", "output_tokens"].iter().enumerate() {
if let Some(t) = answer["usage"][kind].as_u64() {
STATS.jev_tokens[n].fetch_add(t, Relaxed);
tracing::Span::current().record(*kind, t);
}
}
Ok(answer)
}
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
@@ -418,6 +480,21 @@ mod tests {
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
}
#[test]
fn on_the_servers_key_one_item_is_one_call_for_everyone() {
let row = |user_id, guid: &str| Unjudged {
user_id, key: None, feed_id: "f".into(), guid: guid.into(), feed_title: None, title: None, description: None,
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
};
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
let shape = |share| calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::<Vec<_>>()).collect::<Vec<_>>();
assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both");
assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key");
let both: Vec<&Ask> = calls(&items, true)[0].iter().flat_map(|i| &i.signals).collect();
let r = request(&items[0], &both);
assert!(r["questions"]["signal_10"].is_object() && r["questions"]["signal_20"].is_object());
}
#[test]
fn a_batch_is_full_by_its_rows_not_its_items() {
let item = |n: usize| Unjudged {
@@ -443,7 +520,7 @@ mod tests {
Ask { id: 4, name: "Food".into(), keywords: vec!["cooking".into()], description: Some("Recipes, not restaurants.".into()) },
],
};
let r = request(&item);
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
let q = &r["questions"]["signal_3"];
assert_eq!(q["type"], "noul");

View File

@@ -389,6 +389,7 @@ async fn me(
"typesafe_key_set": state.ctx.db.typesafe_key_set(user.id).await.unwrap_or(false),
// Whose key Signals are judged on (#271): "server", for everyone, or "own", each person's.
"signals": if crate::signals::server_key(&state.ctx).is_some() { "server" } else { "own" },
"signal_days": state.ctx.cfg().general.signal_days,
}))
}
@@ -2332,6 +2333,7 @@ struct Settings {
directory_token_set: bool,
directory_share: bool,
signals_on_server: bool,
signal_days: u64,
/// Whether the server has a TypeSafe key (`TYPESAFE_KEY`) for `signals_on_server` to use.
typesafe_server_key: bool,
}
@@ -2351,6 +2353,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
directory_share: cfg.general.directory_share,
signals_on_server: cfg.general.signals_on_server,
signal_days: cfg.general.signal_days,
typesafe_server_key: crate::signals::typesafe_key().is_some(),
})
}
@@ -2369,6 +2372,8 @@ struct SettingsPatch {
directory_token: Option<String>,
directory_share: Option<bool>,
signals_on_server: Option<bool>,
/// 1 to 365.
signal_days: Option<u64>,
}
async fn patch_settings(
@@ -2421,6 +2426,14 @@ async fn patch_settings(
if let Some(v) = body.directory_share {
cfg.general.directory_share = v;
}
// A longer window has older items to ask about; a shorter one leaves what was asked.
let wider = body.signal_days.is_some_and(|d| d > cfg.general.signal_days);
if let Some(d) = body.signal_days {
if !(1..=365).contains(&d) {
return Err(ApiError::bad_request("Signals look back between 1 and 365 days"));
}
cfg.general.signal_days = d;
}
let signals_changed = body.signals_on_server.is_some_and(|v| v != cfg.general.signals_on_server);
if let Some(v) = body.signals_on_server {
cfg.general.signals_on_server = v;
@@ -2429,6 +2442,8 @@ async fn patch_settings(
if signals_changed {
// Whoever's key was refused is tried again on whichever key is now used.
state.ctx.signals_refused.lock().unwrap().clear();
}
if signals_changed || wider {
state.ctx.signals.notify_one();
}
Ok(StatusCode::NO_CONTENT)

View File

@@ -77,24 +77,33 @@ scan worker, so a slow TypeSafe never holds up a scan.
`Db::unjudged` finds, newest first, up to 50 items that are:
- in a feed the Signal's owner subscribes to,
- published (or, with no date, first seen) in the last 30 days (`SIGNAL_DAYS`),
- not hidden from them by their block lists,
- not yet in `signal_verdicts` for that Signal,
- for someone with a key that TypeSafe has not refused since the daemon started.
- for someone with a key that TypeSafe has not refused since the daemon started,
- and either published (or, with no date, first seen) in the look-back window and not yet in
`signal_verdicts` for that Signal, or picked out by it before it was edited (`recheck`), however
old.
So a new Signal looks back a month, and after that sees each new item. A show subscribed to today
has its back catalogue skipped, apart from its last month.
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
default, 1 to 365; #276): each item in it is a call, and a month of production's feeds was about
8,000. So a new Signal looks back that far, and after that sees each new item. A show subscribed
to today has its back catalogue skipped, apart from the window.
The recent half reads the index `entries_recent`, on each feed's items by date, the expression it
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274).
Rows for one item and one person are grouped, so an item is one call whatever the number of
Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the
judge sleeps until woken or for 5 minutes.
Signals. On the server's key, everyone's Signals on the same item go in one call too, its text
sent once (#275). Six calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms each,
a month of items took twenty minutes. A full batch means there may be more, and the next pass
starts at once. Otherwise the judge sleeps until woken or for 5 minutes.
### Every answer is kept
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
an item with a row is never asked again. So the cost is one call per item per person, once.
Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is
asked again. The first start with sentences (#259) deleted every verdict, since they answered the
Changing a Signal's name, words or sentence marks what it picked out to be asked again
(`recheck`), and those stay in its list until answered; what it passed over stays passed over, and
new items are asked the new question as they come (#276). The first start with sentences (#259) deleted every verdict, since they answered the
old question.
## TypeSafe
@@ -134,7 +143,9 @@ Sent with `Authorization: Bearer <the key>`, the server's or the person's, for a
```
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
cut to 2,000 characters (`TEXT_CHARS`).
cut to 1,000 characters (`TEXT_CHARS`). It was 2,000 until #275: on 55 real items and six
Signals, 1,000 changed none of 330 verdicts and saved 12% of the input tokens (1,266 to 1,112 a
call); 500 changed one. Most of the input is the questions themselves, not the item.
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
(#257).
@@ -373,10 +384,10 @@ erDiagram
- **Refused keys are remembered in memory**, so a restart tries each once again.
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
words.
- **There is no meter** of how many calls a key has made. It is one call per item in the person's
feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends
about 3,000 calls the first month and then as many as come in.
- **Only the last 30 days** are ever judged, by publish date.
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them. It is
one call per item in the person's feeds as they come, and per item in the window for a new Signal.
- **Only the window** is judged by publish date, apart from what an edit asks again.
## Testing
@@ -387,4 +398,6 @@ erDiagram
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
`/signal.xml` is dated today, since only the last 30 days are judged.
`/signal.xml` is dated today, since only the window is judged. They spend the TypeSafe key, so
each runs only when the Signals code or the test has changed since it last passed (`signalsTest`;
`IPX_SIGNALS_TESTS=1` runs them anyway).

View File

@@ -275,6 +275,12 @@ ts("Signals judge", [prom('max(ipx_signals_last_pass_age_seconds{job="ipx"})', l
{"matcher": {"id": "byName", "options": "keys refused"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
y += 8
ts("Tokens", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
0, w=12, bars=True, stack=True, desc="What TypeSafe counted for each call, as its answers say: what Signals, icons and key checks cost.")
ts("Tokens a call", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval])) / ignoring(kind) group_left '
'sum(increase(ipx_jev_requests_total{job="ipx", result="ok"}[$__rate_interval]))', legend="{{kind}}")],
12, w=12, desc="An item's text is most of the input; on the server's key, one call carries everyone's Signals on that item.")
y += 8
# ---- ipx: the process, what it holds between requests and jobs (#194)
row("ipx: process")

View File

@@ -219,7 +219,7 @@ export const ipx = {
},
{
method: 'POST', path: '/api/signals', slug: 'post-signals',
summary: 'Make a Signal. It looks back over the last 30 days, then at each new item.',
summary: 'Make a Signal. It looks back over the last week (an admin’s setting), then at each new item.',
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
body: 'SignalBody', returns: '{ id: i64 }',
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
@@ -227,7 +227,7 @@ export const ipx = {
},
{
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
summary: 'Change a Signal. A new name, words or description reads everything again; a new icon does not.',
summary: 'Change a Signal. A new name, words or description asks again about what it picked out, and about new items as they come; a new icon asks nothing.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
body: 'SignalBody', returns: '204 No Content',
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
@@ -431,6 +431,7 @@ export const ipx = {
['version', 'String', 'ipx’s version.'],
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
['signal_days', 'u64', 'How many days back a new Signal reads.'],
] },
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
@@ -573,6 +574,7 @@ export const ipx = {
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
['directory_share', 'bool', ''],
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
['signal_days', 'u64', 'How many days back a new Signal reads; 7 unless an admin sets it.'],
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
] },
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
@@ -581,6 +583,7 @@ export const ipx = {
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
['directory_share', 'Option<bool>', ''],
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
['signal_days', 'Option<u64>', '1 to 365.'],
] },
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },

View File

@@ -98,7 +98,7 @@
<div class="feature">
<div class="say">
<h3>Tune out the noise</h3>
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last month, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last week, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
</div>
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>

View File

@@ -14,10 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
- Admin, Server: how many days back a new Signal reads (#276).
### Changed
- Unless the server pays for them, Signals are turned on in Settings with your own TypeSafe key, and the Signals folder shows only once they are on (#271).
- A Signal's dialog says how far back it reads, and that an edit reads again only what was picked out (#276).
## [1.1.0] - 2026-10-16

View File

@@ -58,6 +58,11 @@ async function drawServer(){
<span class="hint">${g.typesafe_server_key
? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.'
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
<div class="field"><label for="gsigdays">Signals look back (days)</label>
<input type="number" id="gsigdays" min="1" max="365" value="${g.signal_days}">
<span class="hint">How far back a new Signal reads. Each item read is a call to TypeSafe, so a
longer look back costs more, once for each new Signal. Editing a Signal reads again only what
it had picked out.</span></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
@@ -70,6 +75,7 @@ async function drawServer(){
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0),
signal_days: Math.min(365, Math.max(1, Number($<HTMLInputElement>('#gsigdays').value) || 7)),
// Left alone while the server has no key to offer, rather than switched off.
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
toast('Settings saved');

View File

@@ -151,9 +151,9 @@ function signalModal(sg?: Signal){
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
<div class="field"><label for="sgdesc">In your own words (optional)</label>
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
<span class="hint">Every item in the feeds you subscribe to is read, the last ${plural(S.me?.signal_days??7,'day')} of them
and each new one, and the ones about this subject are pulled out: its name, these words and
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads everything again.':''}</span></div>
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads again what was picked out, and listens for it in new items; what was passed over stays passed over.':''}</span></div>
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);

View File

@@ -86,6 +86,8 @@ interface Me {
typesafe_key_set: boolean;
/// Whose TypeSafe key Signals are judged on (#271): the server's, for everyone, or each person's own.
signals: 'server' | 'own';
/// How many days back a new Signal reads (#276), an admin's setting.
signal_days: number;
}
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).