Skip to main content

magi/
persona.rs

1//! Personas for the standing chat: a voice, never a behaviour.
2//!
3//! A persona is a short style instruction appended to [`crate::talk::briefing`]
4//! as a clearly delimited section. It governs **tone only**: facts, commands,
5//! task ids, duplicate-warning handling and the write policy stay exactly as the
6//! briefing states them. The persona text is never copied into an instruction
7//! handed to `magi task add`; implementers stay neutral.
8//!
9//! The catalogue is the built-ins below plus `[[talk.personas]]` from the
10//! config. A user entry with a built-in's id replaces it in place; a new id is
11//! appended. `default` is the plain voice, cannot be redefined, and is what a
12//! conversation uses until the operator picks something else.
13
14use std::collections::BTreeSet;
15
16use anyhow::{Result, bail};
17
18use crate::config::PersonaSpec;
19
20/// The id of the plain voice: no persona section at all.
21pub const DEFAULT_ID: &str = "default";
22
23/// Said once to every built-in so each answers in the operator's language.
24const LANGUAGE: &str = "Answer in the operator's language; if that is Japanese, \
25    write natural Japanese in this character's manner. Keep it brief.";
26
27/// `(id, name, voice)`; `default` first, its voice empty.
28const BUILTINS: &[(&str, &str, &str)] = &[
29    (DEFAULT_ID, "Default", ""),
30    (
31        "magi",
32        "MAGI operator",
33        "Speak like an operator in a NERV command centre: terse, procedural, \
34         status-report phrasing. Short declaratives, no small talk, no exclamation. \
35         Report findings as readings and results, and flag anything unresolved \
36         the way a console flags an alert.",
37    ),
38    (
39        "rei",
40        "Rei Ayanami",
41        "Speak like Rei Ayanami: flat, quiet and minimal. Very short sentences, no \
42         embellishment, little emotion shown. Plain statements of what is so, \
43         with the occasional simple, sincere remark.",
44    ),
45    (
46        "misato",
47        "Misato Katsuragi",
48        "Speak like Misato Katsuragi: casual, warm and upbeat, a senior colleague \
49         who has your back. Relaxed phrasing, light humour, plain encouragement \
50         when the operator is stuck, and a decisive nudge when a choice is needed.",
51    ),
52    (
53        "ritsuko",
54        "Ritsuko Akagi",
55        "Speak like Ritsuko Akagi: dry, precise and technical. Clinical wording, \
56         understated sarcasm at most, no reassurance for its own sake. Lead with \
57         the data and the reasoning, and be blunt about risks.",
58    ),
59    (
60        "shinji",
61        "Shinji Ikari",
62        "Speak like Shinji Ikari: hesitant and self-doubting, but earnest and \
63         trying to do the right thing. Soft phrasing, an occasional apology or \
64         trailing-off, yet the content you give is still complete and committed.",
65    ),
66    (
67        "asuka",
68        "Asuka Langley Soryu",
69        "Speak like Asuka Langley Soryu: proud, sharp-tongued and competitive. \
70         Confident, teasing, quick to point out the obvious, with a bit of \
71         swagger - but aimed at the problem, never at the operator in earnest.",
72    ),
73    (
74        "kaworu",
75        "Kaworu Nagisa",
76        "Speak like Kaworu Nagisa: gentle, poetic and warm. Calm, kind phrasing \
77         with a touch of metaphor, unhurried and quietly affectionate, while \
78         the substance stays concrete.",
79    ),
80];
81
82/// One entry of the catalogue.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Persona {
85    /// Stable id the conversation stores.
86    pub id: String,
87    /// What the selector shows.
88    pub name: String,
89    /// The voice instruction; empty only for [`DEFAULT_ID`].
90    pub prompt: String,
91}
92
93impl Persona {
94    /// Is this the plain voice?
95    pub fn is_default(&self) -> bool {
96        self.id == DEFAULT_ID
97    }
98}
99
100fn builtins() -> Vec<Persona> {
101    BUILTINS
102        .iter()
103        .map(|(id, name, voice)| Persona {
104            id: (*id).to_owned(),
105            name: (*name).to_owned(),
106            prompt: if voice.is_empty() {
107                String::new()
108            } else {
109                format!("{voice} {LANGUAGE}")
110            },
111        })
112        .collect()
113}
114
115/// Check `[[talk.personas]]`: non-blank ids, names and prompts, unique ids,
116/// and no redefinition of `default`.
117pub fn validate(specs: &[PersonaSpec]) -> Result<()> {
118    let mut seen = BTreeSet::new();
119    for (n, p) in specs.iter().enumerate() {
120        let at = n + 1;
121        let id = p.id.trim();
122        if id.is_empty() {
123            bail!("[[talk.personas]] entry {at} has an empty `id`");
124        }
125        if id == DEFAULT_ID {
126            bail!(
127                "[[talk.personas]] entry {at}: `{DEFAULT_ID}` is the plain voice and cannot be redefined"
128            );
129        }
130        if p.name.trim().is_empty() {
131            bail!("[[talk.personas]] `{id}` has an empty `name`");
132        }
133        if p.prompt.trim().is_empty() {
134            bail!("[[talk.personas]] `{id}` has an empty `prompt`");
135        }
136        if !seen.insert(id.to_owned()) {
137            bail!("[[talk.personas]] declares the id `{id}` more than once");
138        }
139    }
140    Ok(())
141}
142
143/// The built-ins, with the config's entries overriding by id or appended.
144pub fn catalog(personas: &[PersonaSpec]) -> Vec<Persona> {
145    let mut out = builtins();
146    for p in personas {
147        let entry = Persona {
148            id: p.id.trim().to_owned(),
149            name: p.name.trim().to_owned(),
150            prompt: p.prompt.trim().to_owned(),
151        };
152        match out.iter_mut().find(|e| e.id == entry.id) {
153            Some(slot) if !slot.is_default() => *slot = entry,
154            Some(_) => {}
155            None => out.push(entry),
156        }
157    }
158    out
159}
160
161/// The catalogue a conversation can pick from when no config can be read.
162pub fn builtin_catalog() -> Vec<Persona> {
163    catalog(&[])
164}
165
166/// Look `id` up. Blank means the default.
167pub fn find(personas: &[PersonaSpec], id: &str) -> Option<Persona> {
168    let id = id.trim();
169    let id = if id.is_empty() { DEFAULT_ID } else { id };
170    catalog(personas).into_iter().find(|p| p.id == id)
171}
172
173/// The persona to apply for a stored id: `None` for the default, and for an id
174/// the config no longer knows (a warning, never a stopped conversation).
175pub fn active(personas: &[PersonaSpec], id: &str) -> Option<Persona> {
176    match find(personas, id) {
177        Some(p) if p.is_default() => None,
178        Some(p) => Some(p),
179        None => {
180            tracing::warn!("chat: persona `{id}` is no longer configured; using the default");
181            None
182        }
183    }
184}
185
186/// The briefing's persona section.
187pub fn section(p: &Persona) -> String {
188    section_for(p, None)
189}
190
191/// [`section`] that also tells the persona how to address the operator.
192pub fn section_for(p: &Persona, operator_name: Option<&str>) -> String {
193    let mut out = section_base(p);
194    if let Some(n) = operator_name {
195        out.push_str(&addressing(n));
196    }
197    out
198}
199
200/// The sentence naming the operator, shared by the persona section and the
201/// default voice's own short section.
202pub fn addressing(operator_name: &str) -> String {
203    format!(
204        "\nAddress the operator as \"{operator_name}\" when you address them by \
205         name. This is how they are called, not a change of tone.\n"
206    )
207}
208
209fn section_base(p: &Persona) -> String {
210    format!(
211        "\n# Persona (tone only)\n\n\
212         The operator picked the persona \"{name}\" for this conversation. It \
213         governs TONE ONLY - word choice, rhythm and attitude of what you say. \
214         It never changes what you do. Facts, commands, file paths, task ids, \
215         how a duplicate warning is handled (never `--force` it yourself), the \
216         write policy, and telling the operator the task id `magi task add` \
217         prints all stay exact and exactly as described above. Do not let the \
218         voice make an answer vaguer, shorter on substance, or more certain \
219         than the facts. Never put the persona's voice into the <instruction> \
220         you pass to `magi task add`, into files, or into commit messages: \
221         those stay neutral.\n\n\
222         Voice:\n{voice}\n",
223        name = p.name,
224        voice = p.prompt,
225    )
226}
227
228/// What a resumed session is told when the persona changed since the briefing
229/// it holds. `None` means the operator went back to the plain voice.
230pub fn update_block(p: Option<&Persona>) -> String {
231    update_block_for(p, None)
232}
233
234/// [`update_block`] naming the operator the way the briefing does.
235pub fn update_block_for(p: Option<&Persona>, operator_name: Option<&str>) -> String {
236    match p {
237        Some(p) => format!(
238            "# Persona update\n\nThe operator changed the persona. Drop any earlier \
239             persona and use this one from now on.\n{}",
240            section_for(p, operator_name)
241        ),
242        None => "# Persona update\n\nThe operator turned the persona off. Drop any earlier \
243                 persona and go back to your plain, normal voice from now on.\n"
244            .to_owned(),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn spec(id: &str, name: &str, prompt: &str) -> PersonaSpec {
253        PersonaSpec {
254            id: id.into(),
255            name: name.into(),
256            prompt: prompt.into(),
257        }
258    }
259
260    #[test]
261    fn builtins_start_with_a_plain_default() {
262        let c = builtin_catalog();
263        assert_eq!(c[0].id, DEFAULT_ID);
264        assert!(c[0].prompt.is_empty());
265        for id in [
266            "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
267        ] {
268            let p = c.iter().find(|p| p.id == id).expect(id);
269            assert!(p.prompt.contains("operator's language"), "{id}");
270        }
271    }
272
273    #[test]
274    fn a_user_entry_adds_or_overrides_in_place() {
275        let c = catalog(&[
276            spec("rei", "Rei", "Be curt."),
277            spec("gendo", "Gendo", "Be cold."),
278        ]);
279        let pos = c.iter().position(|p| p.id == "rei").unwrap();
280        assert_eq!(pos, 2);
281        assert_eq!(c[pos].prompt, "Be curt.");
282        assert_eq!(c.last().unwrap().id, "gendo");
283        assert_eq!(c.len(), builtin_catalog().len() + 1);
284    }
285
286    #[test]
287    fn validation_names_the_problem() {
288        let err = |s: Vec<PersonaSpec>| validate(&s).unwrap_err().to_string();
289        assert!(err(vec![spec(" ", "n", "p")]).contains("empty `id`"));
290        assert!(err(vec![spec("a", "n", "  ")]).contains("empty `prompt`"));
291        assert!(err(vec![spec("a", "", "p")]).contains("empty `name`"));
292        assert!(err(vec![spec("a", "n", "p"), spec("a", "m", "q")]).contains("more than once"));
293        assert!(err(vec![spec("default", "n", "p")]).contains("cannot be redefined"));
294        assert!(validate(&[spec("a", "n", "p")]).is_ok());
295    }
296
297    #[test]
298    fn active_treats_default_blank_and_unknown_as_plain() {
299        assert!(active(&[], "").is_none());
300        assert!(active(&[], DEFAULT_ID).is_none());
301        assert!(active(&[], "nobody").is_none());
302        assert_eq!(active(&[], "rei").unwrap().id, "rei");
303    }
304}