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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user