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_<id>. 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 17:48:06 +00:00
parent 8d93b58deb
commit 20f53183ac
2 changed files with 87 additions and 31 deletions

View File

@@ -70,6 +70,12 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key"); tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
refused.insert(item.user_id); 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. // 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 = 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<bool> {
.await .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<u16> {
e.downcast_ref::<Refused>().map(|r| r.status)
}
fn refuses_key(e: &anyhow::Error) -> bool { fn refuses_key(e: &anyhow::Error) -> bool {
e.downcast_ref::<reqwest::Error>() matches!(status(e), Some(401 | 402 | 403))
.and_then(|e| e.status()) }
.is_some_and(|s| matches!(s.as_u16(), 401 | 402 | 403))
fn malformed(e: &anyhow::Error) -> bool {
matches!(status(e), Some(400 | 422))
} }
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> { async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned()); let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
let answer: serde_json::Value = ctx let span = tracing::info_span!(
.client "jev",
.post(url) user = item.user_id,
.bearer_auth(&item.key) signal_count = item.signals.len(),
.json(&request(item)) "http.response.status_code" = tracing::field::Empty,
.timeout(feed::FEED_TIMEOUT) error = tracing::field::Empty,
.send() );
.instrument(tracing::info_span!("jev", user = item.user_id, signal_count = item.signals.len())) let answer: serde_json::Value = async {
.await? let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).timeout(feed::FEED_TIMEOUT).send().await?;
.error_for_status()? let status = r.status().as_u16();
.json() 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?; .await?;
// A question Jev left unanswered stays unjudged and is asked again. // 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()) 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 { fn request(item: &Unjudged) -> serde_json::Value {
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect(); 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<String, serde_json::Value> = let questions: serde_json::Map<String, serde_json::Value> =
item.signals.iter().map(|(id, words)| (id.to_string(), question(words))).collect(); item.signals.iter().map(|(id, words)| (key(*id), question(words))).collect();
serde_json::json!({ serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
"model": "jev-latest",
"state": { "feed": item.feed_title, "title": item.title, "text": text },
"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 { fn question(words: &[String]) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"type": "noul", "type": "noul",
"instructions": format!("Is this item about any of these: {}?", words.join(", ")), "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. /// Whether Jev says the item fits Signal `id`; None when the answer does not say.
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> { fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
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. /// Show notes are HTML: Jev is given their words.
@@ -153,7 +200,9 @@ mod tests {
#[test] #[test]
fn a_noul_of_a_half_or_more_is_a_fit() { 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, 3), Some(true));
assert_eq!(hit(&answer, 4), Some(false)); assert_eq!(hit(&answer, 4), Some(false));
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again"); 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()])], signals: vec![(3, vec!["formula 1".into(), "f1".into()]), (4, vec!["cooking".into()])],
}; };
let r = request(&item); let r = request(&item);
assert_eq!(r["state"]["text"], "Qualifying report"); assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
assert_eq!(r["questions"]["3"]["type"], "noul"); let q = &r["questions"]["signal_3"];
assert!(r["questions"]["3"]["instructions"].as_str().unwrap().contains("formula 1, f1")); assert_eq!(q["type"], "noul");
assert!(r["questions"]["4"]["instructions"].as_str().unwrap().contains("cooking")); 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"));
} }
} }

View File

@@ -7,13 +7,18 @@ const dir = __dirname;
const port = Number(process.env.FIXTURE_PORT || 8792); const port = Number(process.env.FIXTURE_PORT || 8792);
http.createServer((req, res) => { 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') { if (req.method === 'POST' && req.url === '/jev') {
let body = ''; let body = '';
req.on('data', c => { body += c; }); req.on('data', c => { body += c; });
req.on('end', () => { req.on('end', () => {
const { state, questions } = JSON.parse(body); 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 = {}; const answers = {};
for (const [id, q] of Object.entries(questions)) { for (const [id, q] of Object.entries(questions)) {
const words = q.instructions.replace(/^.*: /, '').replace(/\?$/, '').split(', '); const words = q.instructions.replace(/^.*: /, '').replace(/\?$/, '').split(', ');