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 {
Written by the parents to replace the default. None uses the default prompt, which is
written for the child's profile at every turn.
19 pub system_prompt: Option<String>,
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,
A relaunch within this long of the last activity does not say hello again.
The adapters a conversation runs through. The log, memory and chat are shared with the
background memory work (Reflector); the rest are used here alone or are read-only.
Who the child is; read at every turn, so a change in the settings applies to the next one.
Text that may be spoken to her. The only way to obtain one is
Conversation::respond, which makes one from an answer the guard allowed
or from a fixed fallback line. A shell that speaks only Speakable
cannot speak a model error or an unchecked answer.
What the memory should consider after she has been answered. Only a real answer has any.
At most this many pictures go with one message.
68const MAX_PICTURES: usize = 4;
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 }
Whether Whiskers should say hello: the first time ever, or after a long quiet.
The fixed hello at the start of a chat, which tells her she is talking to an 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 }
Takes what the child said (and showed) and returns what Whiskers may say 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 }
Jev judges words, not pictures. So the pictures are first described in plain words by a separate call that has no persona, and the guard judges that description exactly as it judges her speech. Any failure keeps the pictures 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 }
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 }
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}