1use std::collections::BTreeSet;
15
16use anyhow::{Result, bail};
17
18use crate::config::PersonaSpec;
19
20pub const DEFAULT_ID: &str = "default";
22
23const 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
27const 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#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Persona {
85 pub id: String,
87 pub name: String,
89 pub prompt: String,
91}
92
93impl Persona {
94 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
115pub 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
143pub 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
161pub fn builtin_catalog() -> Vec<Persona> {
163 catalog(&[])
164}
165
166pub 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
173pub 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
186pub fn section(p: &Persona) -> String {
188 format!(
189 "\n# Persona (tone only)\n\n\
190 The operator picked the persona \"{name}\" for this conversation. It \
191 governs TONE ONLY - word choice, rhythm and attitude of what you say. \
192 It never changes what you do. Facts, commands, file paths, task ids, \
193 how a duplicate warning is handled (never `--force` it yourself), the \
194 write policy, and telling the operator the task id `magi task add` \
195 prints all stay exact and exactly as described above. Do not let the \
196 voice make an answer vaguer, shorter on substance, or more certain \
197 than the facts. Never put the persona's voice into the <instruction> \
198 you pass to `magi task add`, into files, or into commit messages: \
199 those stay neutral.\n\n\
200 Voice:\n{voice}\n",
201 name = p.name,
202 voice = p.prompt,
203 )
204}
205
206pub fn update_block(p: Option<&Persona>) -> String {
209 match p {
210 Some(p) => format!(
211 "# Persona update\n\nThe operator changed the persona. Drop any earlier \
212 persona and use this one from now on.\n{}",
213 section(p)
214 ),
215 None => "# Persona update\n\nThe operator turned the persona off. Drop any earlier \
216 persona and go back to your plain, normal voice from now on.\n"
217 .to_owned(),
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn spec(id: &str, name: &str, prompt: &str) -> PersonaSpec {
226 PersonaSpec {
227 id: id.into(),
228 name: name.into(),
229 prompt: prompt.into(),
230 }
231 }
232
233 #[test]
234 fn builtins_start_with_a_plain_default() {
235 let c = builtin_catalog();
236 assert_eq!(c[0].id, DEFAULT_ID);
237 assert!(c[0].prompt.is_empty());
238 for id in [
239 "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
240 ] {
241 let p = c.iter().find(|p| p.id == id).expect(id);
242 assert!(p.prompt.contains("operator's language"), "{id}");
243 }
244 }
245
246 #[test]
247 fn a_user_entry_adds_or_overrides_in_place() {
248 let c = catalog(&[
249 spec("rei", "Rei", "Be curt."),
250 spec("gendo", "Gendo", "Be cold."),
251 ]);
252 let pos = c.iter().position(|p| p.id == "rei").unwrap();
253 assert_eq!(pos, 2);
254 assert_eq!(c[pos].prompt, "Be curt.");
255 assert_eq!(c.last().unwrap().id, "gendo");
256 assert_eq!(c.len(), builtin_catalog().len() + 1);
257 }
258
259 #[test]
260 fn validation_names_the_problem() {
261 let err = |s: Vec<PersonaSpec>| validate(&s).unwrap_err().to_string();
262 assert!(err(vec![spec(" ", "n", "p")]).contains("empty `id`"));
263 assert!(err(vec![spec("a", "n", " ")]).contains("empty `prompt`"));
264 assert!(err(vec![spec("a", "", "p")]).contains("empty `name`"));
265 assert!(err(vec![spec("a", "n", "p"), spec("a", "m", "q")]).contains("more than once"));
266 assert!(err(vec![spec("default", "n", "p")]).contains("cannot be redefined"));
267 assert!(validate(&[spec("a", "n", "p")]).is_ok());
268 }
269
270 #[test]
271 fn active_treats_default_blank_and_unknown_as_plain() {
272 assert!(active(&[], "").is_none());
273 assert!(active(&[], DEFAULT_ID).is_none());
274 assert!(active(&[], "nobody").is_none());
275 assert_eq!(active(&[], "rei").unwrap().id, "rei");
276 }
277}