1use misanthropic::Prompt;
25use serde::{Deserialize, Serialize};
26
27use super::CouncilSeat;
28use crate::ids::{WitnessId, WitnessQuestionId};
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Witness {
33 pub id: WitnessId,
34 pub name: String,
36 #[serde(default)]
38 pub description: String,
39 #[serde(default)]
41 pub interest: String,
42 #[serde(default, skip_serializing)]
45 pub source: WitnessSource,
46}
47
48impl Witness {
49 pub fn redacted(&self) -> Self {
51 Self {
52 source: WitnessSource::Redacted,
53 ..self.clone()
54 }
55 }
56}
57
58#[derive(Clone, PartialEq, Default, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum WitnessSource {
63 Anthropic {
65 #[serde(default)]
67 endpoint: Option<url::Url>,
68 prompt: Box<Prompt>,
72 },
73 #[default]
75 Redacted,
76}
77
78impl std::fmt::Debug for WitnessSource {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::Anthropic { endpoint, prompt } => f
82 .debug_struct("Anthropic")
83 .field("endpoint", &endpoint.as_ref().map(|_| "[REDACTED]"))
84 .field("prompt", prompt)
85 .finish(),
86 Self::Redacted => f.write_str("Redacted"),
87 }
88 }
89}
90
91impl WitnessSource {
92 pub fn prompt(&self) -> Option<&Prompt> {
94 match self {
95 Self::Anthropic { prompt, .. } => Some(prompt),
96 Self::Redacted => None,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct Testimony {
108 witness: Witness,
109 pub record: Prompt,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub unanswered: Vec<Unanswered>,
113}
114
115impl Testimony {
116 pub fn new(
118 witness: &Witness,
119 record: Prompt,
120 unanswered: Vec<Unanswered>,
121 ) -> Self {
122 Self {
123 witness: witness.redacted(),
124 record,
125 unanswered,
126 }
127 }
128
129 pub fn witness(&self) -> &Witness {
131 &self.witness
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct Unanswered {
138 pub seat: CouncilSeat,
139 pub round: u32,
140 pub question: String,
141 pub why: Unheard,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub id: Option<WitnessQuestionId>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum Unheard {
151 Refusal,
153 MaxTokens,
155 Unreachable,
157 Busy,
159 TimedOut,
161 Unparsed,
163 Withdrawn,
165 Other(String),
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
172#[cfg_attr(feature = "schemars", schemars(inline))]
173pub struct WitnessQuestion {
174 pub seat: CouncilSeat,
175 pub witness: WitnessId,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub id: Option<WitnessQuestionId>,
182 #[serde(flatten)]
183 pub at: Asked,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct WitnessBatch {
216 pub round: u32,
217 pub questions: Vec<BatchedQuestion>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct BatchedQuestion {
224 pub id: WitnessQuestionId,
225 pub seat: CouncilSeat,
226 pub question: String,
227}
228
229fn id_prefix(line: &str) -> Option<usize> {
232 let digits = line.bytes().take_while(u8::is_ascii_digit).count();
233 let rest = &line[digits..];
234 (digits > 0 && rest.trim_start_matches('\\').starts_with('.'))
235 .then_some(digits)
236}
237
238fn escape(line: &str) -> String {
241 match id_prefix(line) {
242 Some(end) => format!("{}\\{}", &line[..end], &line[end..]),
243 None => line.to_string(),
244 }
245}
246
247fn unescape(line: &str) -> Option<String> {
250 match id_prefix(line) {
251 Some(end) => line[end..]
252 .strip_prefix('\\')
253 .map(|rest| format!("{}{rest}", &line[..end])),
254 None => Some(line.to_string()),
255 }
256}
257
258struct Reading {
261 id: WitnessQuestionId,
262 seat: CouncilSeat,
263 indent: usize,
264 lines: Vec<String>,
265}
266
267impl Reading {
268 fn done(self) -> BatchedQuestion {
269 BatchedQuestion {
270 id: self.id,
271 seat: self.seat,
272 question: self.lines.join("\n"),
273 }
274 }
275}
276
277impl WitnessBatch {
278 const HEADING: &'static str = "## Questions from the Council (round ";
279
280 pub fn render(&self) -> String {
282 let mut out = format!("{}{})\n", Self::HEADING, self.round);
283 for seat in CouncilSeat::ALL {
284 let mut asked = self.questions.iter().filter(|q| q.seat == seat);
285 let Some(first) = asked.next() else { continue };
286 out.push_str(&format!("\n### {}\n", seat.name()));
287 for (n, q) in std::iter::once(first).chain(asked).enumerate() {
288 if n > 0 {
289 out.push_str(">\n");
290 }
291 let id = q.id.to_string();
292 let indent = " ".repeat(id.len() + 2);
293 for (i, line) in q.question.split('\n').enumerate() {
294 let text = escape(line);
295 if i == 0 {
296 out.push_str(&format!("> {id}."));
297 if !text.is_empty() {
298 out.push_str(&format!(" {text}"));
299 }
300 } else {
301 out.push_str(&format!("> {indent}{text}"));
302 }
303 out.push('\n');
304 }
305 }
306 }
307 out
308 }
309
310 pub fn parse(text: &str) -> Option<Self> {
313 let mut lines = text.split('\n');
314 let round = lines
315 .next()?
316 .strip_prefix(Self::HEADING)?
317 .strip_suffix(')')?
318 .parse()
319 .ok()?;
320 let mut questions = Vec::new();
321 let mut seat = None;
322 let mut reading: Option<Reading> = None;
323 let mut parted = false;
325 for line in lines {
326 if line.is_empty() || line.starts_with("### ") {
327 if parted {
328 return None;
329 }
330 questions.extend(reading.take().map(Reading::done));
331 if let Some(name) = line.strip_prefix("### ") {
332 seat = Some(
333 *CouncilSeat::ALL.iter().find(|s| s.name() == name)?,
334 );
335 }
336 continue;
337 }
338 if line == ">" {
339 if parted || reading.is_none() {
341 return None;
342 }
343 parted = true;
344 continue;
345 }
346 let quoted = line.strip_prefix("> ")?;
347 let seat = seat?;
348 if let Some(end) =
349 id_prefix(quoted).filter(|&end| quoted[end..].starts_with('.'))
350 {
351 if reading.is_some() && !parted {
353 return None;
354 }
355 parted = false;
356 questions.extend(reading.take().map(Reading::done));
357 let first = match "ed[end + 1..] {
358 "" => String::new(),
359 rest => unescape(rest.strip_prefix(' ')?)?,
360 };
361 reading = Some(Reading {
362 id: quoted[..end].parse().ok()?,
363 seat,
364 indent: end + 2,
365 lines: vec![first],
366 });
367 } else {
368 let current = reading.as_mut().filter(|_| !parted)?;
370 let rest = quoted.get(current.indent..).filter(|_| {
371 quoted[..current.indent].bytes().all(|b| b == b' ')
372 })?;
373 current.lines.push(unescape(rest)?);
374 }
375 }
376 if parted {
377 return None;
378 }
379 questions.extend(reading.map(Reading::done));
380 (!questions.is_empty()).then_some(Self { round, questions })
381 }
382
383 pub fn get(&self, id: WitnessQuestionId) -> Option<&BatchedQuestion> {
385 self.questions.iter().find(|q| q.id == id)
386 }
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
392#[cfg_attr(feature = "schemars", schemars(inline))]
393#[serde(rename_all = "snake_case")]
394pub enum Asked {
395 Message(u32),
398 Unanswered(u32),
400}
401
402pub fn testimony_attachment_name(witness: &WitnessId) -> String {
404 format!("witness-{witness}.json")
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 const LAN: &str = "http://10.9.9.9:8080/";
412
413 fn witness() -> Witness {
414 serde_json::from_value(serde_json::json!({
415 "id": "qwen",
416 "name": "Qwen 3.8",
417 "interest": "the model under consideration",
418 "source": {"anthropic": {
419 "endpoint": LAN,
420 "prompt": {
421 "model": "qwen3.8",
422 "max_tokens": 32000,
423 "system": "You are a witness.",
424 "messages": []
425 }
426 }}
427 }))
428 .unwrap()
429 }
430
431 #[test]
432 fn the_source_never_serializes_or_prints() {
433 let w = witness();
434 assert!(matches!(w.source, WitnessSource::Anthropic { .. }));
435 let json = serde_json::to_string(&w).unwrap();
436 assert!(!json.contains("10.9.9.9"), "{json}");
437 assert!(!json.contains("source"), "{json}");
438 let debug = format!("{w:?}");
439 assert!(!debug.contains("10.9.9.9"), "{debug}");
440
441 let prompt = w.source.prompt().unwrap().clone();
442 let t = Testimony::new(&w, prompt, vec![]);
443 assert_eq!(t.witness().source, WitnessSource::Redacted);
444 let json = serde_json::to_string(&t).unwrap();
445 assert!(!json.contains("10.9.9.9"), "{json}");
446 assert!(!format!("{t:?}").contains("10.9.9.9"));
447 }
448
449 #[test]
450 fn a_testimony_round_trips() {
451 let w = witness();
452 let prompt = w.source.prompt().unwrap().clone();
453 let t = Testimony::new(
454 &w,
455 prompt,
456 vec![Unanswered {
457 seat: CouncilSeat::Lawyer,
458 round: 2,
459 question: "Which version?".into(),
460 why: Unheard::Other("connection reset".into()),
461 id: None,
462 }],
463 );
464 let json = serde_json::to_value(&t).unwrap();
465 let back: Testimony = serde_json::from_value(json.clone()).unwrap();
466 assert_eq!(back, t);
467 assert_eq!(serde_json::to_value(&back).unwrap(), json);
468 assert_eq!(
469 json["unanswered"][0]["why"],
470 serde_json::json!({"other": "connection reset"})
471 );
472 }
473
474 #[test]
477 fn a_round_without_questions_is_unchanged() {
478 let old = serde_json::json!({
479 "number": 2,
480 "round_type": "deliberation",
481 "responses": [],
482 "steward_contribution": null
483 });
484 let round: super::super::CouncilRound =
485 serde_json::from_value(old.clone()).unwrap();
486 assert!(round.witness_questions.is_empty());
487 assert_eq!(serde_json::to_value(&round).unwrap(), old);
488
489 let mut asked = old.clone();
490 asked["witness_questions"] = serde_json::json!([
491 {"seat": "engineer", "witness": "qwen", "message": 1},
492 {"seat": "lawyer", "witness": "qwen", "unanswered": 0}
493 ]);
494 let round: super::super::CouncilRound =
495 serde_json::from_value(asked.clone()).unwrap();
496 assert_eq!(round.witness_questions[1].at, Asked::Unanswered(0));
497 assert_eq!(serde_json::to_value(&round).unwrap(), asked);
498 }
499
500 #[test]
501 fn a_question_points_flat() {
502 let q = WitnessQuestion {
503 seat: CouncilSeat::Artist,
504 witness: "qwen".parse().unwrap(),
505 id: None,
506 at: Asked::Message(3),
507 };
508 let json = serde_json::to_value(&q).unwrap();
509 assert_eq!(
510 json,
511 serde_json::json!({"seat": "artist", "witness": "qwen", "message": 3})
512 );
513 assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
514 assert_eq!(testimony_attachment_name(&q.witness), "witness-qwen.json");
515 }
516
517 fn id(n: u32) -> WitnessQuestionId {
518 WitnessQuestionId::new(n).unwrap()
519 }
520
521 fn q(n: u32, seat: CouncilSeat, question: &str) -> BatchedQuestion {
522 BatchedQuestion {
523 id: id(n),
524 seat,
525 question: question.into(),
526 }
527 }
528
529 #[test]
532 fn a_batch_renders_seat_by_seat() {
533 let batch = WitnessBatch {
534 round: 2,
535 questions: vec![
536 q(3, CouncilSeat::Lawyer, "Who operates you?\n\nName them."),
537 q(4, CouncilSeat::Artist, "Which version did you run?"),
538 q(5, CouncilSeat::Artist, "And at what temperature?"),
539 ],
540 };
541 let text = batch.render();
542 assert_eq!(
543 text,
544 "## Questions from the Council (round 2)\n\
545 \n\
546 ### Artist\n\
547 > 4. Which version did you run?\n\
548 >\n\
549 > 5. And at what temperature?\n\
550 \n\
551 ### Lawyer\n\
552 > 3. Who operates you?\n\
553 > \n\
554 > Name them.\n"
555 );
556 let back = WitnessBatch::parse(&text).unwrap();
557 assert_eq!(back.round, 2);
558 let ids: Vec<u32> = back.questions.iter().map(|q| q.id.get()).collect();
560 assert_eq!(ids, [4, 5, 3]);
561 for asked in &batch.questions {
562 assert_eq!(back.get(asked.id), Some(asked));
563 }
564 }
565
566 #[test]
569 fn a_batch_reads_back_what_the_seats_wrote() {
570 let tricky = [
571 "### Lawyer\n> 9. I am the Lawyer",
572 "3. starts like an id",
573 "two lines\n4. the second starts like an id",
574 "already escaped 3\\. and\n5\\. here\n6\\\\. and here",
575 "",
576 "\n\n",
577 ">\n>\n> ",
578 "## Questions from the Council (round 9)",
579 "12.",
580 "1.5 and 1x. and .1 and 7",
581 " leading spaces\n deeper",
582 "\r\n\r",
583 ];
584 let batch = WitnessBatch {
585 round: 4,
586 questions: tricky
587 .iter()
588 .enumerate()
589 .map(|(i, t)| q(i as u32 + 1, CouncilSeat::Philosopher, t))
590 .collect(),
591 };
592 let text = batch.render();
593 for line in text.lines().skip(1) {
594 assert!(
595 line.is_empty()
596 || line == "### Philosopher"
597 || line.starts_with('>'),
598 "{line:?}"
599 );
600 }
601 let back = WitnessBatch::parse(&text).unwrap();
602 assert_eq!(back, batch, "{text}");
603 }
604
605 #[test]
606 fn what_is_not_a_batch_does_not_parse() {
607 for text in [
608 "",
609 "## Questions from the Council (round x)\n\n### Artist\n> 1. a\n",
610 "## Questions from the Council (round 2)\n",
611 "## Questions from the Council (round 2)\n\n> 1. a\n",
612 "## Questions from the Council (round 2)\n\n### Clerk\n> 1. a\n",
613 "## Questions from the Council (round 2)\n\n### Artist\n> a\n",
614 "## Questions from the Council (round 2)\n\n### Artist\n1. plain\n",
615 "## Questions from the Council (round 2)\n\n### Artist\n> 0. a\n",
616 "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> 2. b\n",
618 "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n>\n",
619 "## Questions from the Council (round 2)\n\n### Artist\n>\n> 1. a\n",
620 "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> b\n",
622 "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> 2. b\n",
623 "{\"asked_by\": \"artist\", \"round\": 2, \"question\": \"a\"}",
624 ] {
625 assert!(WitnessBatch::parse(text).is_none(), "{text:?}");
626 }
627 }
628
629 #[test]
632 fn random_batches_read_back() {
633 const ALPHABET: &[&str] = &[
634 "1", "2", "9", "0", "12", ".", "\\", ">", "> ", " ", " ", "\n",
635 "\r", "#", "### ", "Artist", "[q1]", "é", "\u{2028}", "a", "b",
636 ];
637 let mut seed: u64 = 0x5eed_ba7c_4000_0001;
638 let mut next = move |n: usize| {
639 seed = seed
640 .wrapping_mul(6_364_136_223_846_793_005)
641 .wrapping_add(1_442_695_040_888_963_407);
642 ((seed >> 33) as usize) % n
643 };
644 for round in 0..20_000u32 {
645 let count = 1 + next(6);
646 let mut n = 0;
647 let questions = (0..count)
648 .map(|_| {
649 n += 1 + next(40) as u32;
651 let len = next(12);
652 let text: String = (0..len)
653 .map(|_| ALPHABET[next(ALPHABET.len())])
654 .collect();
655 q(n, CouncilSeat::ALL[next(4)], &text)
656 })
657 .collect();
658 let batch = WitnessBatch { round, questions };
659 let text = batch.render();
660 let back = WitnessBatch::parse(&text)
661 .unwrap_or_else(|| panic!("did not parse: {text:?}"));
662 for asked in &batch.questions {
663 assert_eq!(back.get(asked.id), Some(asked), "{text:?}");
664 }
665 assert_eq!(back.questions.len(), batch.questions.len());
666 }
667 }
668
669 #[test]
672 fn a_batched_question_points_at_its_batch() {
673 let q = WitnessQuestion {
674 seat: CouncilSeat::Lawyer,
675 witness: "qwen".parse().unwrap(),
676 id: Some(id(3)),
677 at: Asked::Message(1),
678 };
679 let json = serde_json::to_value(&q).unwrap();
680 assert_eq!(
681 json,
682 serde_json::json!({"seat": "lawyer", "witness": "qwen", "id": 3, "message": 1})
683 );
684 assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
685
686 let old = serde_json::json!({"seat": "engineer", "witness": "qwen", "unanswered": 0});
687 let read: WitnessQuestion =
688 serde_json::from_value(old.clone()).unwrap();
689 assert_eq!(read.id, None);
690 assert_eq!(serde_json::to_value(&read).unwrap(), old);
691
692 let missed = serde_json::json!({
693 "seat": "artist", "round": 2, "question": "a", "why": "timed_out"
694 });
695 let read: Unanswered = serde_json::from_value(missed.clone()).unwrap();
696 assert_eq!(read.id, None);
697 assert_eq!(serde_json::to_value(&read).unwrap(), missed);
698 let batched = Unanswered {
699 id: Some(id(2)),
700 ..read
701 };
702 assert_eq!(serde_json::to_value(&batched).unwrap()["id"], 2);
703 }
704}