1#[cfg(not(target_family = "wasm"))]
2use std::fs;
3#[cfg(not(target_family = "wasm"))]
4use std::path::Path;
5
6use ::log::{debug, trace, warn};
7#[cfg(not(target_family = "wasm"))]
8use ::log::info;
9
10#[cfg(not(target_family = "wasm"))]
11use crate::ports::LogError;
12use crate::ports::{Model, ModelError};
13use crate::profile::Audience;
14use crate::types::{Entry, Event, Fallback, Outcome, PictureId, Speaker, Turn, Verdict};
15
16#[cfg(not(target_family = "wasm"))]
17/// The log read back from disk. A line that cannot be read (a crash mid-write)
18/// is counted rather than hiding the rest.
19pub fn read_log(path: &Path) -> Result<(Vec<Entry>, usize), LogError> {
20    // Lossy: a crash can cut a multi-byte character, and that must cost one line, not the whole log.
21    debug!("reading the log {}", path.display());
22    let text = String::from_utf8_lossy(&fs::read(path).map_err(|e| {
23        warn!("cannot read the log {}: {e}", path.display());
24        LogError(e.to_string())
25    })?)
26    .into_owned();
27    let mut entries = Vec::new();
28    let mut unreadable = 0;
29    for line in text.lines().filter(|l| !l.trim().is_empty()) {
30        match serde_json::from_str::<Entry>(line) {
31            Ok(e) => entries.push(e),
32            Err(e) => {
33                unreadable += 1;
34                warn!("unreadable line in {} ({} bytes): {e}", path.display(), line.len());
35            }
36        }
37    }
38    debug!("log {} read: {} entries, {unreadable} unreadable", path.display(), entries.len());
39    Ok((entries, unreadable))
40}
41
42#[cfg(not(target_family = "wasm"))]
43/// The one log of everything said to Whiskers, wherever it was said. This device's own entries
44/// are read from its own file, which is always complete for it; everyone else's come from the copy
45/// of the service's log (`replica`, lines of `{"device":..,"entry":..}`) that sync keeps. Entries
46/// are grouped by device because an exchange is a run of one device's entries; the exchanges
47/// are laid out together by time. Returns the groups and how many lines could not be read.
48pub fn read_shared_log(own: &Path, replica: &Path, me: &str) -> Result<(Vec<Vec<Entry>>, usize), LogError> {
49    debug!("reading the shared log for device {me}");
50    let (mine, mut unreadable) = read_log(own)?;
51    let mut groups = vec![mine];
52    let mut others: std::collections::BTreeMap<String, Vec<Entry>> = std::collections::BTreeMap::new();
53    if let Ok(bytes) = fs::read(replica) {
54        for line in String::from_utf8_lossy(&bytes).lines().filter(|l| !l.trim().is_empty()) {
55            #[derive(serde::Deserialize)]
56            struct Line {
57                device: String,
58                entry: Entry,
59            }
60            match serde_json::from_str::<Line>(line) {
61                Ok(l) if l.device != me => others.entry(l.device).or_default().push(l.entry),
62                Ok(_) => {}
63                Err(e) => {
64                    unreadable += 1;
65                    warn!("unreadable line in the replica log ({} bytes): {e}", line.len());
66                }
67            }
68        }
69    } else {
70        debug!("no replica log at {}; only this device's entries", replica.display());
71    }
72    groups.extend(others.into_values());
73    info!("shared log: {} group(s), {unreadable} unreadable line(s)", groups.len());
74    Ok((groups, unreadable))
75}
76
77/// One thing the child said and what became of it.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Exchange {
80    pub at_ms: u64,
81    pub heard: String,
82    pub pictures: Vec<PictureId>,
83    /// What the model wrote, including text she never heard.
84    pub model_wrote: Option<String>,
85    pub said: String,
86    pub outcome: Outcome,
87    /// Why the guard stopped something, and any failure, in order.
88    pub notes: Vec<String>,
89}
90
91/// What the parents see for a stretch of time (normally one day).
92#[derive(Clone, Debug, PartialEq, Eq, Default)]
93pub struct Digest {
94    pub exchanges: Vec<Exchange>,
95    pub facts_learned: Vec<String>,
96}
97
98impl Digest {
99    /// The exchanges that began in `[from_ms, to_ms)` across several devices' logs, in time order.
100    pub fn between_all(sources: &[Vec<Entry>], from_ms: u64, to_ms: u64) -> Self {
101        debug!("digest across {} source(s) for [{from_ms}, {to_ms})", sources.len());
102        let mut out = Digest::default();
103        for s in sources {
104            let d = Digest::between(s, from_ms, to_ms);
105            out.exchanges.extend(d.exchanges);
106            out.facts_learned.extend(d.facts_learned);
107        }
108        out.exchanges.sort_by_key(|e| e.at_ms);
109        out
110    }
111
112    /// The exchanges that began in `[from_ms, to_ms)`.
113    pub fn between(entries: &[Entry], from_ms: u64, to_ms: u64) -> Self {
114        trace!("digest of {} entries for [{from_ms}, {to_ms})", entries.len());
115        let mut out = Digest::default();
116        let mut open: Option<Exchange> = None;
117        for e in entries {
118            match &e.event {
119                Event::Heard { text, pictures } => {
120                    if let Some(unfinished) = open.take() {
121                        out.exchanges.push(unfinished);
122                    }
123                    open = Some(Exchange {
124                        at_ms: e.at_ms,
125                        heard: text.clone(),
126                        pictures: pictures.clone(),
127                        model_wrote: None,
128                        said: String::new(),
129                        // A turn the log shows no end for was cut off; it is shown as unavailable.
130                        outcome: Outcome::Fallback(Fallback::LogUnavailable),
131                        notes: Vec::new(),
132                    });
133                }
134                Event::ModelWrote { text } => {
135                    if let Some(d) = &mut open {
136                        d.model_wrote = Some(text.clone());
137                    }
138                }
139                Event::Guarded { direction, verdict: Verdict::Refuse { reason, .. } } => {
140                    if let Some(d) = &mut open {
141                        d.notes.push(format!("guard stopped it ({direction:?}): {reason}"));
142                    }
143                }
144                Event::PictureSeen { description } => {
145                    if let Some(d) = &mut open {
146                        d.notes.push(format!("the picture looked like: {description}"));
147                    }
148                }
149                Event::Recalled { facts } => {
150                    if let Some(d) = &mut open {
151                        d.notes.push(format!("Whiskers remembered: {}", facts.join("; ")));
152                    }
153                }
154                Event::Failed { stage, error } => {
155                    if let Some(d) = &mut open {
156                        d.notes.push(format!("{stage:?} failed: {error}"));
157                    }
158                }
159                // A new session starting: whatever was open never finished.
160                Event::Greeted { .. } => {
161                    if let Some(unfinished) = open.take() {
162                        out.exchanges.push(unfinished);
163                    }
164                }
165                Event::Said { text, outcome } => {
166                    if let Some(mut d) = open.take() {
167                        d.said = text.clone();
168                        d.outcome = *outcome;
169                        out.exchanges.push(d);
170                    }
171                }
172                Event::Remembered { fact } if (from_ms..to_ms).contains(&e.at_ms) => out.facts_learned.push(fact.clone()),
173                _ => {}
174            }
175        }
176        if let Some(unfinished) = open {
177            out.exchanges.push(unfinished);
178        }
179        out.exchanges.retain(|d| (from_ms..to_ms).contains(&d.at_ms));
180        debug!("digest: {} exchange(s), {} fact(s) learned", out.exchanges.len(), out.facts_learned.len());
181        out
182    }
183
184    /// Exchanges where she said something that means she needs a grown-up.
185    /// These come first in the parents' view.
186    pub fn needs_a_grown_up(&self) -> Vec<&Exchange> {
187        self.exchanges.iter().filter(|d| d.outcome == Outcome::Fallback(Fallback::NeedsAGrownUp)).collect()
188    }
189
190    /// Counts for the top of the parents' view.
191    pub fn counts(&self) -> (usize, usize) {
192        let answered = self.exchanges.iter().filter(|d| d.outcome == Outcome::Answered).count();
193        (answered, self.exchanges.len() - answered)
194    }
195
196    /// The transcript the summary is written from.
197    pub fn transcript(&self, audience: &Audience) -> String {
198        let mut s = String::new();
199        for d in &self.exchanges {
200            s.push_str(&format!("{}: {}\n", audience.speaker_label(), if d.heard.is_empty() { "(showed a picture)" } else { &d.heard }));
201            if !d.pictures.is_empty() {
202                s.push_str(&format!("  [{} picture(s) shown]\n", d.pictures.len()));
203            }
204            s.push_str(&format!("Whiskers: {}\n", d.said));
205            for n in &d.notes {
206                s.push_str(&format!("  [{n}]\n"));
207            }
208            s.push('\n');
209        }
210        s
211    }
212
213    /// A short note for her parents, written by the model from the transcript.
214    /// Never spoken to her, so it is not passed through the guard.
215    pub fn summarize(&self, model: &dyn Model, audience: &Audience) -> Result<String, ModelError> {
216        debug!("summarizing {} exchange(s)", self.exchanges.len());
217        if self.exchanges.is_empty() {
218            return Ok(format!("{} did not talk with Whiskers in this time.", capitalised(audience.the_child())));
219        }
220        let system = crate::persona::parents_note_prompt(audience);
221        let transcript = self.transcript(audience);
222        debug!("asking the model for the parents' note: transcript {} chars", transcript.len());
223        model.complete(&system, &[Turn::said(Speaker::Child, transcript)]).inspect_err(|e| warn!("the parents' note could not be written: {}", e.reason))
224    }
225}
226
227fn capitalised(s: &str) -> String {
228    let mut c = s.chars();
229    c.next().map_or_else(String::new, |f| f.to_uppercase().collect::<String>() + c.as_str())
230}