lib.rs held 2,000 lines: fetching, parsing, OPML, Patreon, discovery, artwork, categories, Jev and redaction, and their tests. It is now the module list and the re-exports, so ipx and the directory call feed::fetch, feed::parse and the rest as before: fetch.rs fetch and its limits parse.rs RSS and Atom outlines.rs OPML (not opml.rs, which would shadow the opml crate) patreon.rs a creator's shows discover.rs finding a feed wordpress.rs WordPress.com's API failure.rs failures in words privacy.rs redact, looks_private artwork.rs https art, icons category.rs Apple's categories jev.rs Jev's category Moved verbatim, private items pub(crate) where a sibling calls them, calls across modules module::function. Every definition and doc comment line of the old lib.rs is in exactly one of the new files; each test went with what it tests, and all 40 run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
2.8 KiB
Rust
68 lines
2.8 KiB
Rust
//! Asking TypeSafe's Jev which Apple category a feed belongs to.
|
|
|
|
use crate::parse::ParsedFeed;
|
|
use crate::{category, fetch};
|
|
use anyhow::{Result, anyhow};
|
|
|
|
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
|
|
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
|
|
|
|
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
|
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
|
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
|
/// is taken however unsure, since an admin can change it and asking again would cost a call a scan.
|
|
pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) -> Result<String> {
|
|
let items: Vec<&str> = feed.entries.iter().filter_map(|e| e.title.as_deref()).take(15).collect();
|
|
// A subcategory says which category it is in; without that, "Games" or "Drama" is ambiguous.
|
|
let mut criteria = serde_json::Map::new();
|
|
for (category, subs) in category::CATEGORIES {
|
|
criteria.insert(category.to_string(), serde_json::Value::Null);
|
|
for sub in *subs {
|
|
criteria.insert(sub.to_string(), format!("Within {category}").into());
|
|
}
|
|
}
|
|
let body = serde_json::json!({
|
|
"model": "jev-latest",
|
|
"state": { "title": feed.title, "recent_items": items },
|
|
"questions": { "category": {
|
|
"type": "choice",
|
|
"instructions": "Which category best describes what this feed publishes?",
|
|
"criteria": criteria,
|
|
}},
|
|
});
|
|
let answer: serde_json::Value = client
|
|
.post(JEV_URL)
|
|
.bearer_auth(key)
|
|
.json(&body)
|
|
.timeout(fetch::FEED_TIMEOUT)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
picked(&answer)
|
|
}
|
|
|
|
pub(crate) fn picked(answer: &serde_json::Value) -> Result<String> {
|
|
let choice = answer["answers"]["category"]["choice"].as_str().unwrap_or_default();
|
|
category::CATEGORIES
|
|
.iter()
|
|
.flat_map(|(c, subs)| std::iter::once(c).chain(subs.iter()))
|
|
.find(|c| **c == choice)
|
|
.map(|c| c.to_string())
|
|
.ok_or_else(|| anyhow!("no category in the answer: {answer}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
#[test]
|
|
fn picked_takes_only_an_apple_category() {
|
|
let answer = |c: &str| serde_json::json!({"answers": {"category": {"type": "choice", "choice": c}}});
|
|
assert_eq!(super::picked(&answer("Technology")).unwrap(), "Technology");
|
|
assert_eq!(super::picked(&answer("Tech News")).unwrap(), "Tech News", "a subcategory");
|
|
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
|
|
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
|
|
}
|
|
}
|