oxedyne/fe2o3/fe2o3_steel/src/srv/publish/ai.rs
13.4 KiB, 55 runs
created by r1870400018:17345, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The AI settings a site keeps, and the calls it makes with them. |
| 2 | //! |
| 3 | //! One record holds everything the operator sets on the AI panel: which model to call and the key to |
| 4 | //! call it with, the instruction sent with a post being "fixed" and the one sent with a comment being |
| 5 | //! judged, and the addresses to email when a comment is held for a human. The key is a secret and is |
| 6 | //! stored exactly as the destination tokens are -- a `Dat` in the vhost's own database, encrypted at |
| 7 | //! rest under the database's scheme -- so it is not a key in a file in the clear, and it is never |
| 8 | //! logged. |
| 9 | //! |
| 10 | //! The client that makes the call lives upstream in [`oxedyne_fe2o3_net::llm`]; this module is the |
| 11 | //! settings around it, and the two prompts a site sends with its two kinds of request. |
| 12 | //! |
| 13 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 14 | //! Anthropic Claude |
| 15 | |
| 16 | use crate::srv::publish::subscribe; |
| 17 | |
| 18 | use oxedyne_fe2o3_core::prelude::*; |
| 19 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 20 | use oxedyne_fe2o3_iop_db::api::Database; |
| 21 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 22 | use oxedyne_fe2o3_jdat::{ |
| 23 | prelude::*, |
| 24 | id::NumIdDat, |
| 25 | }; |
| 26 | use oxedyne_fe2o3_net::llm::{ |
| 27 | self, |
| 28 | LlmConfig, |
| 29 | Provider, |
| 30 | }; |
| 31 | |
| 32 | use std::sync::{ |
| 33 | Arc, |
| 34 | RwLock, |
| 35 | }; |
| 36 | |
| 37 | use tokio_rustls::rustls::ClientConfig; |
| 38 | |
| 39 | |
| 40 | pub const AI_KEY: &str = "publish/ai"; |
| 41 | |
| 42 | // The instruction sent with a post the author asks to fix, where the operator has set none. |
| 43 | // Deliberately narrow. These blogs are written by hand for their own sake, so the one job this |
| 44 | // prompt gives the model is to correct what is plainly wrong -- spelling, grammar, punctuation -- |
| 45 | // and to leave the voice, the word choice and the argument exactly as they were. The author reviews |
| 46 | // the result before it replaces anything, so the prompt errs towards doing too little rather than |
| 47 | // too much: a fix that changed the meaning is a worse failure than a typo it left alone. |
| 48 | pub const FIX_PROMPT_DEFAULT: &str = |
| 49 | "You are a meticulous copy-editor for a personal blog. Correct only clear errors of spelling, \ |
| 50 | grammar and punctuation in the text below. Do not change the author's wording, voice, tone, \ |
| 51 | structure or meaning; do not add, remove or reorder ideas; do not rewrite for style. If a \ |
| 52 | passage is already correct, leave it untouched. Return only the corrected text, with no preamble, \ |
| 53 | no explanation and no markup you were not given."; |
| 54 | |
| 55 | // The instruction sent with a comment being judged, where the operator has set none. One word out, |
| 56 | // so the reply maps cleanly to a verdict. The three words are the three a moderator can reach: |
| 57 | // publish it, bin it, or hold it for a person. The prompt is told to prefer holding when unsure, |
| 58 | // because the cost of holding a good comment is a short wait and the cost of publishing a bad one |
| 59 | // is a bad comment on the page. |
| 60 | pub const COMMENT_PROMPT_DEFAULT: &str = |
| 61 | "You are moderating reader comments on a personal blog. Judge only the comment text below. Reply \ |
| 62 | with exactly one word and nothing else: APPROVE if it is a genuine, civil, on-topic comment; SPAM \ |
| 63 | if it is advertising, a link farm, gibberish or off-topic promotion; HOLD if you are unsure or it \ |
| 64 | is borderline. Prefer HOLD over APPROVE when in doubt."; |
| 65 | |
| 66 | |
| 67 | /// Everything a site sets on its AI panel. |
| 68 | /// |
| 69 | /// Empty strings are the unset state throughout, not a record of blanks: a blank provider is "no AI", |
| 70 | /// a blank prompt means "use the default", a blank key means "nothing stored". So a fresh site and a |
| 71 | /// site that has cleared its settings read the same, which is what they mean. |
| 72 | #[derive(Clone, Debug, Default)] |
| 73 | pub struct AiSettings { |
| 74 | pub provider: String, // `""` unset, `openrouter`, `fireworks` or `mistral` |
| 75 | pub model: String, // in the provider's own naming |
| 76 | pub api_key: String, // write-only from the console, never logged |
| 77 | pub fix_prompt: String, // empty means FIX_PROMPT_DEFAULT |
| 78 | pub comment_prompt: String, // empty means COMMENT_PROMPT_DEFAULT |
| 79 | // The addresses emailed when a comment is held for a human. Empty means nobody is told, and the |
| 80 | // comment simply waits in the console queue. |
| 81 | pub alert_emails: Vec<String>, |
| 82 | } |
| 83 | |
| 84 | impl AiSettings { |
| 85 | |
| 86 | pub fn to_dat(&self) -> Dat { |
| 87 | let mut m = DaticleMap::new(); |
| 88 | m.insert(dat!("provider"), dat!(self.provider.clone())); |
| 89 | m.insert(dat!("model"), dat!(self.model.clone())); |
| 90 | m.insert(dat!("api_key"), dat!(self.api_key.clone())); |
| 91 | m.insert(dat!("fix_prompt"), dat!(self.fix_prompt.clone())); |
| 92 | m.insert(dat!("comment_prompt"),dat!(self.comment_prompt.clone())); |
| 93 | m.insert(dat!("alert_emails"), |
| 94 | Dat::List(self.alert_emails.iter().map(|e| dat!(e.clone())).collect())); |
| 95 | Dat::Map(m) |
| 96 | } |
| 97 | |
| 98 | /// Tolerant of a missing field, so an older record still reads. |
| 99 | pub fn from_dat(d: &Dat) -> Self { |
| 100 | let s = |m: &DaticleMap, k: &str| match m.get(&dat!(k)) { |
| 101 | Some(Dat::Str(v)) => v.clone(), |
| 102 | _ => String::new(), |
| 103 | }; |
| 104 | match d { |
| 105 | Dat::Map(m) => Self { |
| 106 | provider: s(m, "provider"), |
| 107 | model: s(m, "model"), |
| 108 | api_key: s(m, "api_key"), |
| 109 | fix_prompt: s(m, "fix_prompt"), |
| 110 | comment_prompt: s(m, "comment_prompt"), |
| 111 | alert_emails: match m.get(&dat!("alert_emails")) { |
| 112 | Some(Dat::List(l)) => l.iter().filter_map(|e| match e { |
| 113 | Dat::Str(v) => Some(v.clone()), |
| 114 | _ => None, |
| 115 | }).collect(), |
| 116 | _ => Vec::new(), |
| 117 | }, |
| 118 | }, |
| 119 | _ => Self::default(), |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Whether a call can be made: a provider, a model and a key are all set. |
| 124 | pub fn ready(&self) -> bool { |
| 125 | !self.provider.trim().is_empty() |
| 126 | && !self.model.trim().is_empty() |
| 127 | && !self.api_key.trim().is_empty() |
| 128 | } |
| 129 | |
| 130 | /// The fix instruction to send: the operator's where they set one, the default otherwise. |
| 131 | pub fn fix_prompt(&self) -> &str { |
| 132 | if self.fix_prompt.trim().is_empty() { FIX_PROMPT_DEFAULT } else { &self.fix_prompt } |
| 133 | } |
| 134 | |
| 135 | pub fn comment_prompt(&self) -> &str { |
| 136 | if self.comment_prompt.trim().is_empty() { COMMENT_PROMPT_DEFAULT } else { &self.comment_prompt } |
| 137 | } |
| 138 | |
| 139 | /// The connection config for a call, or the reason there is none. |
| 140 | pub fn llm(&self) -> Outcome<LlmConfig> { |
| 141 | if !self.ready() { |
| 142 | return Err(err!( |
| 143 | "The site's AI is not configured: it needs a provider, a model and a key."; |
| 144 | Invalid, Input, Missing)); |
| 145 | } |
| 146 | Ok(LlmConfig { |
| 147 | provider: res!(Provider::of(self.provider.trim())), |
| 148 | model: self.model.trim().to_string(), |
| 149 | api_key: self.api_key.clone(), |
| 150 | }) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | /// What the model said to do with a comment. |
| 155 | /// |
| 156 | /// The three a moderator can reach, and no more. Kept here rather than in the comment module so the |
| 157 | /// dependency runs one way -- the comment module maps this to its own verdict -- and so the parsing |
| 158 | /// of a model's reply lives beside the prompt that asked for it. |
| 159 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 160 | pub enum CommentVerdict { |
| 161 | Approve, |
| 162 | Spam, |
| 163 | Hold, |
| 164 | } |
| 165 | |
| 166 | /// Reads a one-word reply into a verdict. |
| 167 | /// |
| 168 | /// The prompt asks for one word, but a model is not a promise: it may wrap the word in a sentence, add |
| 169 | /// a full stop, or answer in a different case. So this looks for the first of the three words anywhere |
| 170 | /// in the reply, case-folded, and **falls back to holding** -- the safe direction -- when it finds |
| 171 | /// none. A reply that says nothing recognisable is exactly when a person should look, not when a guess |
| 172 | /// should publish. |
| 173 | pub fn parse_comment_verdict(reply: &str) -> CommentVerdict { |
| 174 | let up = reply.to_uppercase(); |
| 175 | // SPAM is checked before APPROVE so a reply that mentions both ("not spam, approve") is read the |
| 176 | // strict way; a model torn between the two is a comment worth holding, but binning is the safer |
| 177 | // of the two decisive readings and the words rarely co-occur innocently. |
| 178 | if up.contains("SPAM") { |
| 179 | CommentVerdict::Spam |
| 180 | } else if up.contains("APPROVE") { |
| 181 | CommentVerdict::Approve |
| 182 | } else { |
| 183 | CommentVerdict::Hold |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /// Judges a comment's text with the site's model. |
| 188 | /// |
| 189 | /// `None` where the call could not be made -- AI is not configured, or the model would not answer -- |
| 190 | /// so the caller keeps the comment held rather than treating a network failure as a decision. A |
| 191 | /// judgement is only ever `Some` when the model actually spoke. The comment's text is all that is sent; |
| 192 | /// no address, no name, nothing about who wrote it, since none of that is the model's business here. |
| 193 | pub async fn judge_comment( |
| 194 | settings: &AiSettings, |
| 195 | tls: Arc<ClientConfig>, |
| 196 | body: &str, |
| 197 | ) |
| 198 | -> Option<CommentVerdict> |
| 199 | { |
| 200 | let cfg = settings.llm().ok()?; |
| 201 | match llm::complete(&cfg, settings.comment_prompt(), body, tls).await { |
| 202 | Ok(reply) => Some(parse_comment_verdict(&reply)), |
| 203 | Err(_) => None, |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /// Parses an address list a person typed -- one per line, or separated by commas -- keeping the valid |
| 208 | /// ones in order and dropping blanks and duplicates. |
| 209 | /// |
| 210 | /// Lenient on the separators because a person pasting addresses should not have to care which this |
| 211 | /// wanted, and strict on the addresses because an alert sent to a malformed one is an alert lost. |
| 212 | pub fn parse_emails(raw: &str) -> Vec<String> { |
| 213 | let mut out: Vec<String> = Vec::new(); |
| 214 | for part in raw.split(|c| c == '\n' || c == '\r' || c == ',' || c == ';' || c == ' ') { |
| 215 | let e = part.trim().to_lowercase(); |
| 216 | if e.is_empty() || !subscribe::valid_email(&e) { |
| 217 | continue; |
| 218 | } |
| 219 | if !out.iter().any(|x| x == &e) { |
| 220 | out.push(e); |
| 221 | } |
| 222 | } |
| 223 | out |
| 224 | } |
| 225 | |
| 226 | /// The AI settings a site has stored. The default (all empty) where none are stored, which is not an |
| 227 | /// error: a site that wants no AI stores nothing. |
| 228 | pub fn get_settings< |
| 229 | const UIDL: usize, |
| 230 | UID: NumIdDat<UIDL>, |
| 231 | ENC: Encrypter, |
| 232 | KH: Hasher, |
| 233 | DB: Database<UIDL, UID, ENC, KH>, |
| 234 | >( |
| 235 | db: &(Arc<RwLock<DB>>, UID), |
| 236 | ) |
| 237 | -> Outcome<AiSettings> |
| 238 | { |
| 239 | let (db_arc, _) = db; |
| 240 | let guard = lock_read!(db_arc); |
| 241 | match res!(guard.get(&dat!(AI_KEY), None)) { |
| 242 | Some((val, _)) => Ok(AiSettings::from_dat(&val)), |
| 243 | None => Ok(AiSettings::default()), |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | /// Writes a site's AI settings to its store, where the key is encrypted at rest under the database's |
| 248 | /// own scheme -- the same treatment its posts, its sessions and its destination tokens get. |
| 249 | pub fn put_settings< |
| 250 | const UIDL: usize, |
| 251 | UID: NumIdDat<UIDL>, |
| 252 | ENC: Encrypter, |
| 253 | KH: Hasher, |
| 254 | DB: Database<UIDL, UID, ENC, KH>, |
| 255 | >( |
| 256 | db: &(Arc<RwLock<DB>>, UID), |
| 257 | settings: &AiSettings, |
| 258 | ) |
| 259 | -> Outcome<()> |
| 260 | { |
| 261 | let (db_arc, user) = db; |
| 262 | let guard = lock_read!(db_arc); |
| 263 | res!(guard.insert(dat!(AI_KEY), settings.to_dat(), *user, None)); |
| 264 | Ok(()) |
| 265 | } |
| 266 | |
| 267 | |
| 268 | #[cfg(test)] |
| 269 | mod tests { |
| 270 | use super::*; |
| 271 | |
| 272 | /// The settings round-trip through a daticle, the key and all. |
| 273 | #[test] |
| 274 | fn test_settings_round_trip_00() -> Outcome<()> { |
| 275 | let s = AiSettings { |
| 276 | provider: fmt!("mistral"), |
| 277 | model: fmt!("mistral-large-latest"), |
| 278 | api_key: fmt!("sk-secret"), |
| 279 | fix_prompt: fmt!("Fix it."), |
| 280 | comment_prompt: String::new(), |
| 281 | alert_emails: vec![fmt!("me@example.com"), fmt!("also@example.com")], |
| 282 | }; |
| 283 | let back = AiSettings::from_dat(&s.to_dat()); |
| 284 | assert_eq!(back.provider, "mistral"); |
| 285 | assert_eq!(back.model, "mistral-large-latest"); |
| 286 | assert_eq!(back.api_key, "sk-secret"); |
| 287 | assert_eq!(back.fix_prompt, "Fix it."); |
| 288 | assert_eq!(back.alert_emails, vec![fmt!("me@example.com"), fmt!("also@example.com")]); |
| 289 | Ok(()) |
| 290 | } |
| 291 | |
| 292 | /// An empty prompt reads as its default; a set one reads as itself. So the panel can prefill the |
| 293 | /// default without freezing it, and clearing the box restores the default rather than sending none. |
| 294 | #[test] |
| 295 | fn test_a_blank_prompt_is_the_default_01() -> Outcome<()> { |
| 296 | let mut s = AiSettings::default(); |
| 297 | assert_eq!(s.fix_prompt(), FIX_PROMPT_DEFAULT); |
| 298 | assert_eq!(s.comment_prompt(), COMMENT_PROMPT_DEFAULT); |
| 299 | s.fix_prompt = fmt!("Only fix spelling."); |
| 300 | assert_eq!(s.fix_prompt(), "Only fix spelling."); |
| 301 | Ok(()) |
| 302 | } |
| 303 | |
| 304 | /// A call is possible only with a provider, a model and a key; anything short of that is a clear |
| 305 | /// error, not a call that fails at the socket. |
| 306 | #[test] |
| 307 | fn test_ready_needs_all_three_02() -> Outcome<()> { |
| 308 | let mut s = AiSettings::default(); |
| 309 | assert!(!s.ready()); |
| 310 | assert!(s.llm().is_err()); |
| 311 | s.provider = fmt!("openrouter"); |
| 312 | s.model = fmt!("some/model"); |
| 313 | assert!(!s.ready(), "no key is not ready"); |
| 314 | s.api_key = fmt!("k"); |
| 315 | assert!(s.ready()); |
| 316 | let cfg = res!(s.llm()); |
| 317 | assert_eq!(cfg.provider, Provider::OpenRouter); |
| 318 | assert_eq!(cfg.model, "some/model"); |
| 319 | // An unknown provider word is refused at the point of the call. |
| 320 | s.provider = fmt!("nope"); |
| 321 | assert!(s.llm().is_err()); |
| 322 | Ok(()) |
| 323 | } |
| 324 | |
| 325 | /// A one-word reply reads to a verdict; a reply in a sentence, in the wrong case, or saying |
| 326 | /// nothing recognisable still reads safely -- towards holding, never towards publishing on a guess. |
| 327 | #[test] |
| 328 | fn test_comment_verdict_parsing_04() -> Outcome<()> { |
| 329 | assert_eq!(parse_comment_verdict("APPROVE"), CommentVerdict::Approve); |
| 330 | assert_eq!(parse_comment_verdict("spam"), CommentVerdict::Spam); |
| 331 | assert_eq!(parse_comment_verdict("HOLD"), CommentVerdict::Hold); |
| 332 | // Wrapped in a sentence, still read. |
| 333 | assert_eq!(parse_comment_verdict("This looks fine, so APPROVE."), CommentVerdict::Approve); |
| 334 | // Torn between the two decisive words is read the strict way. |
| 335 | assert_eq!(parse_comment_verdict("not spam, I would approve"), CommentVerdict::Spam); |
| 336 | // Nothing recognisable holds, rather than guessing publish. |
| 337 | assert_eq!(parse_comment_verdict("I am not sure about this one."), CommentVerdict::Hold); |
| 338 | assert_eq!(parse_comment_verdict(""), CommentVerdict::Hold); |
| 339 | Ok(()) |
| 340 | } |
| 341 | |
| 342 | /// The address list takes newlines or commas, keeps the valid in order, and drops blanks, |
| 343 | /// duplicates and anything malformed -- since an alert to a bad address is an alert lost. |
| 344 | #[test] |
| 345 | fn test_email_parsing_03() -> Outcome<()> { |
| 346 | let got = parse_emails(" A@Example.com ,\n b@x.org\n\n a@example.com , not-an-email ,c@y.net "); |
| 347 | assert_eq!(got, vec![fmt!("a@example.com"), fmt!("b@x.org"), fmt!("c@y.net")]); |
| 348 | assert!(parse_emails(" \n , ; ").is_empty()); |
| 349 | Ok(()) |
| 350 | } |
| 351 | } |