From 20f53183ac6dc2ee38028b56957d6a5df201e577 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 16 Oct 2026 17:48:06 +0000 Subject: [PATCH] Signals: Jev's request in the shape TypeSafe takes, and its reason kept when it refuses (#257) Every call was refused with 422: a noul question's `criteria` is an object of what true and false mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary" at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status. A request refused as malformed (400, 422) ends the pass, since every other item's would be refused the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the questions are keyed signal_. Checked against TypeSafe with the corrected request: 0.96 for an item about the keywords, 0.02 for one that was not. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/src/signals.rs | 109 +++++++++++++++++++++++++++---------- tests/ui/fixtures/serve.js | 9 ++- 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/crates/ipx/src/signals.rs b/crates/ipx/src/signals.rs index 9b54680..27f3b83 100644 --- a/crates/ipx/src/signals.rs +++ b/crates/ipx/src/signals.rs @@ -70,6 +70,12 @@ async fn pass(ctx: &Ctx) -> Result { tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key"); refused.insert(item.user_id); } + // 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"); + break; + } // 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"), } @@ -83,53 +89,94 @@ async fn pass(ctx: &Ctx) -> Result { .await } +/// 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)] +struct Refused { + status: u16, + body: String, +} + +impl std::fmt::Display for Refused { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "TypeSafe answered {}: {}", self.status, self.body) + } +} + +impl std::error::Error for Refused {} + +fn status(e: &anyhow::Error) -> Option { + e.downcast_ref::().map(|r| r.status) +} + fn refuses_key(e: &anyhow::Error) -> bool { - e.downcast_ref::() - .and_then(|e| e.status()) - .is_some_and(|s| matches!(s.as_u16(), 401 | 402 | 403)) + matches!(status(e), Some(401 | 402 | 403)) +} + +fn malformed(e: &anyhow::Error) -> bool { + matches!(status(e), Some(400 | 422)) } async fn judge(ctx: &Ctx, item: &Unjudged) -> Result> { let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned()); - let answer: serde_json::Value = ctx - .client - .post(url) - .bearer_auth(&item.key) - .json(&request(item)) - .timeout(feed::FEED_TIMEOUT) - .send() - .instrument(tracing::info_span!("jev", user = item.user_id, signal_count = item.signals.len())) - .await? - .error_for_status()? - .json() - .await?; + let span = tracing::info_span!( + "jev", + user = item.user_id, + signal_count = item.signals.len(), + "http.response.status_code" = tracing::field::Empty, + error = tracing::field::Empty, + ); + let answer: serde_json::Value = async { + let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).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(); + tracing::Span::current().record("error", body.as_str()); + return Err(Refused { status, body }.into()); + } + anyhow::Ok(r.json().await?) + } + .instrument(span) + .await?; // A question Jev left unanswered stays unjudged and is asked again. Ok(item.signals.iter().filter_map(|(id, _)| hit(&answer, *id).map(|h| (*id, h))).collect()) } -/// The item as Jev sees it, and one yes-or-no question for each Signal, keyed by its id. +/// 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 { let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect(); + let state = format!( + "Feed: {}\nTitle: {}\n\n{text}", + item.feed_title.as_deref().unwrap_or(""), + item.title.as_deref().unwrap_or("") + ); let questions: serde_json::Map = - item.signals.iter().map(|(id, words)| (id.to_string(), question(words))).collect(); - serde_json::json!({ - "model": "jev-latest", - "state": { "feed": item.feed_title, "title": item.title, "text": text }, - "questions": questions, - }) + item.signals.iter().map(|(id, words)| (key(*id), question(words))).collect(); + serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions }) } +fn key(id: i64) -> String { + format!("signal_{id}") +} + +/// `criteria` is an object of what true and false mean: a sentence on its own was refused with a +/// 422, every call, and no Signal ever filled (#257). fn question(words: &[String]) -> serde_json::Value { serde_json::json!({ "type": "noul", "instructions": format!("Is this item about any of these: {}?", words.join(", ")), - "criteria": "Yes if the item's subject is one of them, or plainly part of one. No if one is only mentioned in passing.", + "criteria": { + "true": "The item's subject is one of them, or plainly part of one.", + "false": "None of them is its subject: one is only mentioned in passing, or none is.", + }, }) } /// Whether Jev says the item fits Signal `id`; None when the answer does not say. fn hit(answer: &serde_json::Value, id: i64) -> Option { - answer["answers"][id.to_string()]["noul"].as_f64().map(|p| p >= THRESHOLD) + answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD) } /// Show notes are HTML: Jev is given their words. @@ -153,7 +200,9 @@ mod tests { #[test] fn a_noul_of_a_half_or_more_is_a_fit() { - let answer = serde_json::json!({ "answers": { "3": { "noul": 0.5 }, "4": { "noul": 0.49 } } }); + // As TypeSafe answered on 2026-10-16. + let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": { + "signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } }); assert_eq!(hit(&answer, 3), Some(true)); assert_eq!(hit(&answer, 4), Some(false)); assert_eq!(hit(&answer, 5), None, "a question not answered is asked again"); @@ -172,9 +221,11 @@ mod tests { signals: vec![(3, vec!["formula 1".into(), "f1".into()]), (4, vec!["cooking".into()])], }; let r = request(&item); - assert_eq!(r["state"]["text"], "Qualifying report"); - assert_eq!(r["questions"]["3"]["type"], "noul"); - assert!(r["questions"]["3"]["instructions"].as_str().unwrap().contains("formula 1, f1")); - assert!(r["questions"]["4"]["instructions"].as_str().unwrap().contains("cooking")); + assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report"); + let q = &r["questions"]["signal_3"]; + assert_eq!(q["type"], "noul"); + assert!(q["instructions"].as_str().unwrap().contains("formula 1, f1")); + assert!(q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(), "TypeSafe takes an object, not a sentence"); + assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("cooking")); } } diff --git a/tests/ui/fixtures/serve.js b/tests/ui/fixtures/serve.js index aa03575..465b7cc 100644 --- a/tests/ui/fixtures/serve.js +++ b/tests/ui/fixtures/serve.js @@ -7,13 +7,18 @@ const dir = __dirname; const port = Number(process.env.FIXTURE_PORT || 8792); http.createServer((req, res) => { - // Jev, for Signals (#254): sure of an item whose title has a question's keyword in it. + // Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses + // a request in the shape TypeSafe refuses, as it does (#257). if (req.method === 'POST' && req.url === '/jev') { let body = ''; req.on('data', c => { body += c; }); req.on('end', () => { const { state, questions } = JSON.parse(body); - const title = (state.title || '').toLowerCase(); + if (Object.values(questions).some(q => typeof q.criteria !== 'object')) { + res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}'); + return; + } + const title = String(state).toLowerCase(); const answers = {}; for (const [id, q] of Object.entries(questions)) { const words = q.instructions.replace(/^.*: /, '').replace(/\?$/, '').split(', ');