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");
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<bool> {
.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 {
e.downcast_ref::<reqwest::Error>()
.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<Vec<(i64, bool)>> {
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<String, serde_json::Value> =
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<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.
@@ -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"));
}
}