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 {
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(&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    }
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}