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");
|
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);
|
||||||
.await?;
|
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.
|
// 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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(', ');
|
||||||
|
|||||||
Reference in New Issue
Block a user