1use crate::text::{find_word, fold};
20use crate::words::ShieldWords;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Tone {
27 #[default]
28 Calm,
29 Rude,
31 Hostile,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Topic {
39 Work,
41 Support,
43 Press,
44 Thanks,
45 Donation,
47 #[default]
48 Other,
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Assessment {
54 pub tone: Tone,
55 pub topic: Topic,
56 #[serde(default)]
58 pub words: Vec<String>,
59 #[serde(default)]
61 pub summary: String,
62 #[serde(default)]
63 pub by_ai: bool,
64}
65
66fn positions(folded: &[char], phrase: &str) -> Vec<usize> {
68 let mut out = Vec::new();
69 let mut from = 0;
70 while let Some(at) = find_word(folded, phrase, from) {
71 out.push(at);
72 from = at + 1;
73 }
74 out
75}
76
77fn near_you(you: &[String], folded: &[char], at: usize) -> bool {
80 let start = at.saturating_sub(40);
81 let end = (at + 40).min(folded.len());
82 let window = &folded[start..end];
83 you.iter().any(|w| {
84 let elided: Vec<char> = fold(w.trim());
85 find_word(window, w, 0).is_some() || (w.trim().ends_with('\'') && !elided.is_empty() && window.windows(elided.len()).any(|p| p == elided.as_slice()))
86 })
87}
88
89fn shouts(text: &str) -> bool {
91 let letters: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
92 letters.len() >= 40 && letters.iter().filter(|c| c.is_uppercase()).count() * 10 >= letters.len() * 6
93}
94
95pub fn assess(subject: &str, text: &str) -> Assessment {
98 assess_with(&crate::words::current().shield, subject, text)
99}
100
101pub fn assess_with(lists: &ShieldWords, subject: &str, text: &str) -> Assessment {
104 let whole = format!("{subject}\n{text}");
105 let folded = fold(&whole);
106 let mut score = 0.0f32;
107 let mut words = Vec::new();
108 for threat in &lists.threats {
109 if !positions(&folded, threat).is_empty() {
110 score += 3.0;
111 words.push(threat.to_string());
112 }
113 }
114 for insult in &lists.insults {
115 for at in positions(&folded, insult) {
116 score += if near_you(&lists.you, &folded, at) { 3.0 } else { 1.5 };
117 if !words.iter().any(|w| w == insult) {
118 words.push(insult.to_string());
119 }
120 }
121 }
122 let mut rude = 0.0f32;
123 for word in &lists.rude {
124 let found = positions(&folded, word).len();
125 if found > 0 {
126 rude += found as f32;
127 if !words.iter().any(|w| w == word) {
128 words.push(word.to_string());
129 }
130 }
131 }
132 if shouts(text) {
133 rude += 1.0;
134 }
135 if whole.matches("!!").count() >= 3 {
136 rude += 0.5;
137 }
138 let tone = if score >= 3.0 || score + rude >= 4.0 {
139 Tone::Hostile
140 } else if score > 0.0 || rude >= 1.0 {
141 Tone::Rude
142 } else {
143 Tone::Calm
144 };
145 let t = &lists.topics;
147 let topic = [(Topic::Work, &t.work), (Topic::Support, &t.support), (Topic::Press, &t.press), (Topic::Thanks, &t.thanks), (Topic::Donation, &t.donation)]
148 .into_iter()
149 .map(|(topic, keys)| (keys.iter().map(|k| positions(&folded, k).len()).sum::<usize>(), topic))
150 .filter(|(n, _)| *n > 0)
151 .max_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)))
152 .map_or(Topic::Other, |(_, t)| t);
153 Assessment { tone, topic, words, summary: String::new(), by_ai: false }
154}
155
156pub fn ai_key(card: &crate::card::Card) -> Option<String> {
160 let id = crate::mailindex::bare_id(card.message_id.as_deref()?);
161 let mut digest: u64 = 0xcbf2_9ce4_8422_2325;
162 for byte in [card.from_address.as_deref().unwrap_or(""), card.subject.as_str(), card.excerpt.as_str()].iter().flat_map(|part| part.bytes().chain(std::iter::once(0))) {
163 digest ^= u64::from(byte);
164 digest = digest.wrapping_mul(0x0100_0000_01b3);
165 }
166 Some(format!("{id}#{digest:016x}"))
167}
168
169pub fn reading(card: &crate::card::Card, answers: Option<&std::collections::BTreeMap<String, Assessment>>) -> Assessment {
175 answers.and_then(|answers| answers.get(&ai_key(card)?)).cloned().unwrap_or_else(|| assess(&card.subject, &card.excerpt))
176}
177
178pub fn ai_prompt(subject: &str, text: &str) -> String {
180 let excerpt: String = text.chars().take(4000).collect();
181 format!(
182 "You screen mail sent to a public contact address, so that its owner, who is vulnerable to harassment, never reads hostile text. \
183 Answer with one line of JSON and nothing else: {{\"tone\": \"calm\" | \"rude\" | \"hostile\", \"topic\": \"work\" | \"support\" | \"press\" | \"thanks\" | \"donation\" | \"other\", \"summary\": \"…\"}}. \
184 tone: hostile for insults aimed at the reader, harassment, threats, intimidation; rude for swearing or contempt not aimed at the reader; calm otherwise. \
185 topic: work for a job, a mission, a quote, a collaboration; support for a question about software. \
186 summary: at most 15 neutral words saying what the message asks, in the message's language, without quoting any insult.\n\nSubject: {subject}\n\n{excerpt}"
187 )
188}
189
190pub fn read_ai_answer(answer: &str) -> Option<Assessment> {
192 #[derive(Deserialize)]
193 struct Answer {
194 tone: Tone,
195 topic: Topic,
196 #[serde(default)]
197 summary: String,
198 }
199 let start = answer.find('{')?;
200 let end = answer.rfind('}')?;
201 let parsed: Answer = serde_json::from_str(answer.get(start..=end)?).ok()?;
203 Some(Assessment { tone: parsed.tone, topic: parsed.topic, words: Vec::new(), summary: parsed.summary.chars().take(200).collect(), by_ai: true })
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
210pub struct AiCache {
211 #[serde(default)]
212 pub messages: std::collections::BTreeMap<String, Assessment>,
213 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
217 pub malformed: std::collections::BTreeMap<String, u32>,
218}
219
220impl AiCache {
221 pub const TIMES: u32 = 2;
224
225 pub fn wants(&self, id: &str) -> bool {
228 !self.messages.contains_key(id) && self.malformed.get(id).copied().unwrap_or(0) < AiCache::TIMES
229 }
230
231 pub fn folder() -> std::path::PathBuf {
232 crate::config::state_dir().join("shield")
233 }
234
235 pub fn path(account: &str) -> std::path::PathBuf {
236 AiCache::folder().join(format!("{account}.toml"))
237 }
238
239 pub fn load(account: &str) -> AiCache {
240 std::fs::read_to_string(AiCache::path(account)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
241 }
242
243 pub fn load_all() -> std::collections::BTreeMap<String, Assessment> {
245 let Ok(entries) = std::fs::read_dir(AiCache::folder()) else { return Default::default() };
246 entries
247 .filter_map(Result::ok)
248 .filter_map(|e| std::fs::read_to_string(e.path()).ok())
249 .filter_map(|t| toml::from_str::<AiCache>(&t).ok())
250 .flat_map(|c| c.messages)
251 .collect()
252 }
253
254 pub fn save(&self, account: &str) -> Result<(), String> {
255 let path = AiCache::path(account);
256 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
257 if let Some(parent) = path.parent() {
258 std::fs::create_dir_all(parent).map_err(fail)?;
259 }
260 let temporary = path.with_extension("toml.new");
261 std::fs::write(&temporary, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
262 std::fs::rename(&temporary, &path).map_err(fail)
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn tone_and_topic() {
272 let job = assess("Mission de conseil", "Bonjour, nous recrutons un consultant en traitement d'image pour une mission de trois mois. Quel serait votre tarif ?");
273 assert_eq!((job.tone, job.topic), (Tone::Calm, Topic::Work));
274 let support = assess("The app crashes on export", "Hi, since the last update the app crashes when I export a file. How to fix this bug?");
275 assert_eq!((support.tone, support.topic), (Tone::Calm, Topic::Support));
276 let rude = assess("Export", "This export module is garbage, what the fuck.");
277 assert_eq!(rude.tone, Tone::Rude);
278 let insult = assess("Ton logiciel", "T'es qu'un connard prétentieux, ton logiciel est nul.");
279 assert_eq!(insult.tone, Tone::Hostile);
280 assert!(insult.words.contains(&"connard".to_string()));
281 let threat = assess("hey", "I know where you live.");
282 assert_eq!(threat.tone, Tone::Hostile);
283 assert_eq!(assess("Merci", "Merci pour la connaissance partagée, c'est un contact précieux.").tone, Tone::Calm);
285 assert_eq!(assess("Merci", "Merci pour vos articles, quelle reconnaissance !").topic, Topic::Thanks);
286 }
287
288 #[test]
290 fn your_words_are_read() {
291 let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.shield.insults]\nadd = [\"Mistkerl\"]\n[words.shield.you]\nadd = [\"du\"]\n[words.shield.topics.support]\nadd = [\"pixelmill\"]\n").unwrap();
292 let yours = crate::words::Words::of(&config);
293 assert_eq!(assess_with(&yours.shield, "Hallo", "Du bist ein Mistkerl.").tone, Tone::Hostile);
294 assert_eq!(assess("Hallo", "Du bist ein Mistkerl.").tone, Tone::Calm, "not in the packs");
295 assert_eq!(assess_with(&yours.shield, "pixelmill", "Une question sur pixelmill.").topic, Topic::Support);
296 assert_eq!(assess("Toi", "T'es qu'un connard.").tone, Tone::Hostile);
298 }
299
300 #[test]
301 fn an_ai_answer_is_read() {
302 let read = read_ai_answer("Here: {\"tone\": \"hostile\", \"topic\": \"other\", \"summary\": \"Complains about the export: \\\"rude\\\"\"}").unwrap();
303 assert_eq!((read.tone, read.topic, read.by_ai), (Tone::Hostile, Topic::Other, true));
304 assert!(read.summary.starts_with("Complains"));
305 assert!(read_ai_answer("no json").is_none());
306 assert!(read_ai_answer("} said the model, then {").is_none());
307 assert!(ai_prompt("s", "t").contains("Subject: s"));
308 }
309
310 #[test]
313 fn the_ai_answer_is_found_where_it_is_kept() {
314 let card = |text: &str| crate::card::Card::from_bytes(format!("From: Someone <someone@hostile.example>\r\nSubject: Words for you\r\nMessage-ID: <h-1@hostile.example>\r\n\r\n{text}\r\n").as_bytes()).unwrap();
315 let calm_words = card("Some unkind sentences.");
316 assert_eq!(assess(&calm_words.subject, &calm_words.excerpt).tone, Tone::Calm, "the word lists find nothing");
317 let hostile = Assessment { tone: Tone::Hostile, by_ai: true, ..Assessment::default() };
318 let answers: std::collections::BTreeMap<String, Assessment> = [(ai_key(&calm_words).unwrap(), hostile.clone())].into();
319 assert_eq!(reading(&calm_words, Some(&answers)), hostile);
320 assert!(!answers.contains_key("h-1@hostile.example"));
322 assert_eq!(reading(&card("Thanks for the export module."), Some(&answers)).tone, Tone::Calm);
324 assert!(!reading(&card("Thanks for the export module."), Some(&answers)).by_ai);
325 assert_eq!(reading(&calm_words, None).tone, Tone::Calm);
327 }
328}