Oregami
Repositories/oxedyne/fe2o3

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
16use crate::srv::publish::subscribe;
17
18use oxedyne_fe2o3_core::prelude::*;
19use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
20use oxedyne_fe2o3_iop_db::api::Database;
21use oxedyne_fe2o3_iop_hash::api::Hasher;
22use oxedyne_fe2o3_jdat::{
23 prelude::*,
24 id::NumIdDat,
25};
26use oxedyne_fe2o3_net::llm::{
27 self,
28 LlmConfig,
29 Provider,
30};
31
32use std::sync::{
33 Arc,
34 RwLock,
35};
36
37use tokio_rustls::rustls::ClientConfig;
38
39
40pub 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.
48pub 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.
60pub 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)]
73pub 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
84impl 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)]
160pub 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.
173pub 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.
193pub 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.
212pub 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.
228pub 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.
249pub 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)]
269mod 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}