1use std::sync::Arc; 2 3use ::log::{debug, error, info, trace, warn}; 4 5use crate::persona; 6use crate::profile::SharedProfile; 7use crate::ports::{Clock, FailureStage, Guard, Model, Pictures}; 8use crate::profile::Audience; 9use crate::recall::Recall; 10use crate::reflect::ReflectInput; 11use crate::shared::{SharedChat, SharedLog, SharedMemory}; 12use crate::types::{ 13 Direction, Entry, Event, Fact, Fallback, Heard, Image, Outcome, PictureId, RefusalKind, Speaker, Turn, Verdict, 14}; 15 16pub struct Config { 17 /// Written by the parents to replace the default. `None` uses the default prompt, which is 18 /// written for the child's profile at every turn. 19 pub system_prompt: Option<String>, 20 /// How many of the most recent turns the model is shown word for word (the rest are in the summary). 21 pub history_turns: usize, 22 /// A relaunch within this long of the last activity does not say hello again. 23 pub greet_after_ms: u64, 24} 25 26/// The adapters a conversation runs through. The log, memory and chat are shared with the 27/// background memory work (`Reflector`); the rest are used here alone or are read-only. 28pub struct Parts { 29 pub model: Arc<dyn Model>, 30 pub guard: Arc<dyn Guard>, 31 pub log: SharedLog, 32 pub pictures: Box<dyn Pictures + Send>, 33 pub memory: SharedMemory, 34 pub chat: SharedChat, 35 pub recall: Arc<Recall>, 36 pub clock: Arc<dyn Clock>, 37 /// Who the child is; read at every turn, so a change in the settings applies to the next one. 38 pub profile: SharedProfile, 39} 40 41/// Text that may be spoken to her. The only way to obtain one is 42/// `Conversation::respond`, which makes one from an answer the guard allowed 43/// or from a fixed fallback line. A shell that speaks only `Speakable` 44/// cannot speak a model error or an unchecked answer. 45#[derive(Debug, PartialEq, Eq)] 46pub struct Speakable(String); 47 48impl Speakable { 49 pub fn as_str(&self) -> &str { 50 &self.0 51 } 52} 53 54#[derive(Debug, PartialEq, Eq)] 55pub struct Reply { 56 pub say: Speakable, 57 pub outcome: Outcome, 58 /// What the memory should consider after she has been answered. Only a real answer has any. 59 pub reflect: Option<ReflectInput>, 60} 61 62pub struct Conversation { 63 config: Config, 64 p: Parts, 65} 66 67/// At most this many pictures go with one message. 68const MAX_PICTURES: usize = 4; 69 70impl Conversation { 71 pub fn new(config: Config, parts: Parts) -> Self { 72 debug!( 73 "conversation created: system prompt {} chars, history {} turns, greet after {} ms", 74 config.system_prompt.as_ref().map_or(0, String::len), config.history_turns, config.greet_after_ms 75 ); 76 Self { config, p: parts } 77 } 78 79 /// Whether Whiskers should say hello: the first time ever, or after a long quiet. 80 pub fn greeting_due(&self) -> bool { 81 let s = self.p.chat.snapshot(); 82 let due = s.last_active_ms == 0 || self.p.clock.now_ms().saturating_sub(s.last_active_ms) >= self.config.greet_after_ms; 83 debug!("greeting due = {due} (last active {} ms)", s.last_active_ms); 84 due 85 } 86 87 /// The fixed hello at the start of a chat, which tells her she is talking to an 88 /// AI. It is logged like any other line and does not depend on the model. 89 pub fn greet(&mut self) -> Reply { 90 info!("greeting"); 91 let hello = persona::greeting(&self.p.profile.audience()); 92 let said = Event::Greeted { text: hello.clone() }; 93 if self.record(said).is_err() { 94 return fallback(Fallback::LogUnavailable); 95 } 96 if let Err(e) = self.p.chat.touch(self.p.clock.now_ms()) { 97 warn!("greeting could not touch the chat: {}", e.0); 98 } 99 Reply { say: Speakable(hello), outcome: Outcome::Answered, reflect: None } 100 } 101 102 /// Takes what the child said (and showed) and returns what Whiskers may say 103 /// back. Every path returns a reply. 104 pub fn respond(&mut self, mut heard: Heard) -> Reply { 105 info!("turn begins: {} chars heard, {} picture(s)", heard.text.len(), heard.pictures.len()); 106 // Read once: one turn is judged and answered for one age even if the settings change mid-turn. 107 let audience = self.p.profile.audience(); 108 let age = audience.age(); 109 if heard.pictures.len() > MAX_PICTURES { 110 warn!("{} pictures shown; keeping the first {MAX_PICTURES}", heard.pictures.len()); 111 } 112 heard.pictures.truncate(MAX_PICTURES); 113 // Pictures are kept before anything is sent anywhere, so the parents 114 // can always see what the model was shown. 115 let mut saved: Vec<PictureId> = Vec::new(); 116 for image in &heard.pictures { 117 match self.p.pictures.save(image) { 118 Ok(id) => { 119 debug!("picture kept as {}", id.0); 120 saved.push(id); 121 } 122 Err(e) => { 123 error!("turn ends: a picture could not be kept: {}", e.0); 124 return fallback(Fallback::LogUnavailable); 125 } 126 } 127 } 128 if self.record(Event::Heard { text: heard.text.clone(), pictures: saved.clone() }).is_err() { 129 // The one unlogged utterance: the log is what is broken, and 130 // nothing has been sent to the model or the guard. 131 error!("turn ends: the log is unavailable, so nothing was sent anywhere"); 132 return fallback(Fallback::LogUnavailable); 133 } 134 135 // The guard judges words. A message that is only a picture has none; the 136 // answer is still guarded on the way out. 137 if !heard.text.trim().is_empty() { 138 debug!("guarding what she said ({} chars)", heard.text.len()); 139 match self.p.guard.check(Direction::FromChild, age, &heard.text) { 140 Ok(Verdict::Allow) => { 141 debug!("guard allowed what she said"); 142 if let Err(r) = self.record_guard(Direction::FromChild, Verdict::Allow) { 143 return r; 144 } 145 } 146 Ok(verdict) => { 147 let outcome = match &verdict { 148 Verdict::Refuse { kind: RefusalKind::NeedsAGrownUp, .. } => Fallback::NeedsAGrownUp, 149 _ => Fallback::ChildRefused, 150 }; 151 warn!("guard refused what she said: {outcome:?}"); 152 return self.finish_refused(Direction::FromChild, verdict, outcome); 153 } 154 Err(e) => { 155 error!("guard unreachable for her words: {}", e.0); 156 return self.finish_failed(FailureStage::GuardingInput, e.0, Fallback::GuardUnavailable); 157 } 158 } 159 } 160 161 let mut description = None; 162 if !heard.pictures.is_empty() { 163 match self.screen_pictures(&audience, &heard.pictures) { 164 Ok(d) => description = Some(d), 165 Err(reply) => { 166 warn!("turn ends at the pictures: {:?}", reply.outcome); 167 return reply; 168 } 169 } 170 } 171 172 // Call to mind what bears on this message. This can only add; it never fails the turn. 173 let query = match &description { 174 Some(d) => format!("{} {}", heard.text, d), 175 None => heard.text.clone(), 176 }; 177 let recalled = self.p.recall.search(&self.p.memory, &query); 178 debug!("{} fact(s) recalled for this turn", recalled.len()); 179 if !recalled.is_empty() { 180 let _ = self.record(Event::Recalled { facts: recalled.iter().map(|f| f.text.clone()).collect() }); 181 } 182 let chat = self.p.chat.snapshot(); 183 let system = self.system(&audience, &chat.summary, &recalled); 184 185 let mut shown = chat.turns_for_model(); 186 let keep = self.config.history_turns.saturating_sub(1); 187 if shown.len() > keep { 188 trace!("trimming history from {} to {keep} turns", shown.len()); 189 shown.drain(..shown.len() - keep); 190 } 191 debug!("asking the model: system {} chars, {} turns shown", system.len(), shown.len() + 1); 192 shown.push(Turn { speaker: Speaker::Child, text: heard.text.clone(), pictures: heard.pictures }); 193 let written = match self.p.model.complete(&system, &shown) { 194 Ok(text) => { 195 debug!("model answered with {} chars", text.len()); 196 text 197 } 198 Err(e) => { 199 if e.resting { 200 info!("model is resting (time allowance used): {}", e.reason); 201 } else { 202 error!("model failed: {}", e.reason); 203 } 204 return self.finish_failed(FailureStage::Model, e.reason, if e.resting { Fallback::Resting } else { Fallback::ModelUnavailable }); 205 } 206 }; 207 if self.record(Event::ModelWrote { text: written.clone() }).is_err() { 208 return fallback(Fallback::LogUnavailable); 209 } 210 211 debug!("guarding the answer ({} chars)", written.len()); 212 match self.p.guard.check(Direction::ToChild, age, &written) { 213 Ok(Verdict::Allow) => { 214 debug!("guard allowed the answer"); 215 if let Err(r) = self.record_guard(Direction::ToChild, Verdict::Allow) { 216 return r; 217 } 218 } 219 Ok(verdict) => { 220 warn!("guard refused the model's answer ({} chars); a fixed line is used", written.len()); 221 return self.finish_refused(Direction::ToChild, verdict, Fallback::AnswerRefused); 222 } 223 Err(e) => { 224 error!("guard unreachable for the answer: {}", e.0); 225 return self.finish_failed(FailureStage::GuardingOutput, e.0, Fallback::GuardUnavailable); 226 } 227 } 228 229 let said = Event::Said { text: written.clone(), outcome: Outcome::Answered }; 230 if self.record(said).is_err() { 231 return fallback(Fallback::LogUnavailable); 232 } 233 // Only a turn that was allowed both ways enters the chat, and as words: a picture 234 // is not shown to the model again. 235 let note = if heard.text.trim().is_empty() { "(showed Whiskers a picture)".to_owned() } else { heard.text.clone() }; 236 if let Err(e) = self.p.chat.push_exchange(¬e, &written, self.p.clock.now_ms()) { 237 error!("the exchange could not be saved to the chat: {}", e.0); 238 let _ = self.record(Event::Failed { stage: FailureStage::SavingChat, error: e.0 }); 239 } 240 info!("turn ends: answered ({} chars)", written.len()); 241 let reflect = ReflectInput { heard: note, said: written.clone(), description, pictures: saved }; 242 Reply { say: Speakable(written), outcome: Outcome::Answered, reflect: Some(reflect) } 243 } 244 245 /// Jev judges words, not pictures. So the pictures are first described in plain 246 /// words by a separate call that has no persona, and the guard judges that 247 /// description exactly as it judges her speech. Any failure keeps the pictures 248 /// from the cat. Returns the description. 249 fn screen_pictures(&mut self, audience: &Audience, pictures: &[Image]) -> Result<String, Reply> { 250 debug!("describing {} picture(s)", pictures.len()); 251 let ask = Turn { 252 speaker: Speaker::Child, 253 text: "What is in this picture?".to_owned(), 254 pictures: pictures.to_vec(), 255 }; 256 let description = match self.p.model.complete(&persona::describe_pictures_prompt(audience), &[ask]) { 257 Ok(d) => { 258 debug!("pictures described in {} chars", d.len()); 259 d 260 } 261 Err(e) => { 262 if e.resting { 263 info!("model is resting while describing pictures: {}", e.reason); 264 } else { 265 error!("describing pictures failed: {}", e.reason); 266 } 267 return Err(self.finish_failed(FailureStage::Model, e.reason, if e.resting { Fallback::Resting } else { Fallback::ModelUnavailable })); 268 } 269 }; 270 if self.record(Event::PictureSeen { description: description.clone() }).is_err() { 271 return Err(fallback(Fallback::LogUnavailable)); 272 } 273 match self.p.guard.check(Direction::FromChild, audience.age(), &description) { 274 Ok(Verdict::Allow) => { 275 debug!("guard allowed the picture description"); 276 self.record_guard(Direction::FromChild, Verdict::Allow).map(|()| description) 277 } 278 Ok(verdict) => { 279 let outcome = match &verdict { 280 Verdict::Refuse { kind: RefusalKind::NeedsAGrownUp, .. } => Fallback::NeedsAGrownUp, 281 _ => Fallback::ChildRefused, 282 }; 283 warn!("guard refused the picture description: {outcome:?}"); 284 Err(self.finish_refused(Direction::FromChild, verdict, outcome)) 285 } 286 Err(e) => { 287 error!("guard unreachable for the picture description: {}", e.0); 288 Err(self.finish_failed(FailureStage::GuardingInput, e.0, Fallback::GuardUnavailable)) 289 } 290 } 291 } 292 293 pub fn facts(&self) -> Vec<Fact> { 294 self.p.memory.facts() 295 } 296 297 /// A parent removes something Whiskers remembers. 298 pub fn forget(&mut self, id: u64) -> bool { 299 match self.p.memory.forget(id) { 300 Ok(fact) => { 301 info!("fact {id} forgotten"); 302 let _ = self.record(Event::Forgot { fact: fact.text }); 303 true 304 } 305 Err(e) => { 306 warn!("cannot forget fact {id}: {}", e.0); 307 false 308 } 309 } 310 } 311 312 fn system(&self, audience: &Audience, summary: &str, recalled: &[Fact]) -> String { 313 trace!("building the system prompt: summary {} chars, {} recalled", summary.len(), recalled.len()); 314 let mut s = self.config.system_prompt.clone().unwrap_or_else(|| persona::default_system_prompt(audience)); 315 if !summary.trim().is_empty() { 316 s.push_str(&format!("\n\nWhat you remember of your earlier chats with {}:\n{}", audience.the_child(), summary.trim())); 317 } 318 if !recalled.is_empty() { 319 s.push_str("\n\nThings you remember that may help with what the child just said:\n"); 320 for f in recalled { 321 s.push_str(&format!("- {}", f.text)); 322 let mut extra = Vec::new(); 323 if let Some(p) = &f.place { 324 extra.push(format!("where: {p}")); 325 } 326 if let Some(w) = &f.when { 327 extra.push(format!("when: {w}")); 328 } 329 if !extra.is_empty() { 330 s.push_str(&format!(" ({})", extra.join("; "))); 331 } 332 s.push('\n'); 333 } 334 } 335 s 336 } 337 338 fn record(&self, event: Event) -> Result<(), ()> { 339 let entry = Entry { at_ms: self.p.clock.now_ms(), event }; 340 self.p.log.append(&entry).map_err(|e| error!("the parents' record could not be written: {}", e.0)) 341 } 342 343 fn record_guard(&self, direction: Direction, verdict: Verdict) -> Result<(), Reply> { 344 self.record(Event::Guarded { direction, verdict }).map_err(|()| { 345 warn!("guard verdict for {direction:?} could not be recorded"); 346 fallback(Fallback::LogUnavailable) 347 }) 348 } 349 350 fn finish_refused(&self, direction: Direction, verdict: Verdict, outcome: Fallback) -> Reply { 351 if self.record_guard(direction, verdict).is_err() { 352 return fallback(Fallback::LogUnavailable); 353 } 354 self.finish(outcome) 355 } 356 357 fn finish_failed(&self, stage: FailureStage, error: String, outcome: Fallback) -> Reply { 358 if self.record(Event::Failed { stage, error }).is_err() { 359 return fallback(Fallback::LogUnavailable); 360 } 361 self.finish(outcome) 362 } 363 364 fn finish(&self, outcome: Fallback) -> Reply { 365 let reply = fallback(outcome); 366 info!("turn ends with fallback {outcome:?}"); 367 let said = Event::Said { text: reply.say.0.clone(), outcome: reply.outcome }; 368 // The fallback line is fixed and vetted, so it is spoken even if this 369 // last line cannot be written; the lines before it already are. 370 if self.record(said).is_err() { 371 warn!("the fallback line could not be logged; it is spoken anyway"); 372 } 373 reply 374 } 375} 376 377fn fallback(outcome: Fallback) -> Reply { 378 let line = outcome.line(); 379 Reply { say: Speakable(line.to_owned()), outcome: Outcome::Fallback(outcome), reflect: None } 380}