whiskers.git / crates / whiskers-core / src / conversation.rs
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.

23    pub greet_after_ms: u64,
24}

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.

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>,

Who the child is; read at every turn, so a change in the settings applies to the next one.

38    pub profile: SharedProfile,
39}

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.

45#[derive(Debug, PartialEq, Eq)]
46pub struct Speakable(String);
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,

What the memory should consider after she has been answered. Only a real answer has any.

59    pub reflect: Option<ReflectInput>,
60}
62pub struct Conversation {
63    config: Config,
64    p: Parts,
65}

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.

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    }

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(&note, &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    }
293    pub fn facts(&self) -> Vec<Fact> {
294        self.p.memory.facts()
295    }

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}