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 section_for(p, None)
189}
190
191pub 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
200pub 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
228pub fn update_block(p: Option<&Persona>) -> String {
231 update_block_for(p, None)
232}
233
234pub 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}