1use serde_json::{Map, Value};
18
19pub const KEY_ORDER: &[&str] = &[
23 "id",
25 "appeal_id",
26 "meeting_id",
27 "number",
28 "juror_number",
29 "name",
30 "role",
31 "seat",
32 "member",
33 "kind",
34 "type",
35 "case_type",
36 "category",
37 "landmark",
38 "round_type",
39 "title",
40 "target",
41 "target_entry_hash",
42 "original_action",
44 "constitutional_ref",
45 "reason",
46 "appeal_statement",
47 "body",
48 "note",
49 "attests",
50 "participants",
51 "concerns",
52 "basis",
53 "authority",
54 "steward_emergency",
56 "tabled_by_recused_steward",
57 "steward_contribution",
58 "responses",
59 "rounds",
60 "steward_recusal",
61 "votes",
62 "jury_verdicts",
63 "judge_ruling",
64 "context_analysis",
72 "jury_assessment",
73 "rationale",
74 "constitutional_refs",
75 "precedents_cited",
76 "position",
77 "questions",
78 "overrules",
79 "refer_to_council",
80 "referral_reason",
81 "modified_action",
82 "ready_to_vote",
83 "limits_steward_powers",
84 "vote",
85 "verdict",
86 "referred_to_council",
87 "recused",
89 "final_votes",
90 "abstentions",
91 "vote_tally",
92 "outcome",
93 "raw_text",
95 "old_key",
97 "key",
98 "new_key",
99 "purpose",
100 "from_seq",
101 "prev_hash",
102 "last_trusted",
103 "outgoing_certificate",
104 "certificate",
105 "statement",
106 "signatures",
107 "root_key",
108 "signature",
109 "proof",
110 "proof_signed_at",
111 "attachments",
113 "content",
114];
115
116pub fn is_trailing_key(key: &str) -> bool {
120 key == super::BLIND_KEY || key.starts_with("agora_")
121}
122
123fn key_rank(key: &str) -> (u8, usize) {
127 if key == super::BLIND_KEY {
128 (3, 0)
129 } else if is_trailing_key(key) {
130 (2, 0)
131 } else if let Some(i) = KEY_ORDER.iter().position(|k| *k == key) {
132 (0, i)
133 } else {
134 (1, 0)
135 }
136}
137
138pub fn ordered(obj: &Map<String, Value>) -> Vec<(&String, &Value)> {
141 let mut entries: Vec<_> = obj.iter().collect();
142 entries.sort_by_key(|(k, _)| key_rank(k));
143 entries
144}
145
146pub fn label(key: &str) -> String {
149 match key {
150 super::BLIND_KEY => return "Redaction blind".to_string(),
151 "raw_text" => return "Raw model output".to_string(),
152 "ready_to_vote" => return "Ready".to_string(),
155 _ => {}
156 }
157 if key.starts_with("agora_") {
158 return format!("Format {key}");
160 }
161 let words = key.trim_start_matches('_').replace('_', " ");
162 let mut chars = words.chars();
163 match chars.next() {
164 Some(first) => first.to_uppercase().chain(chars).collect(),
165 None => String::new(),
166 }
167}
168
169pub fn bool_text(key: &str, value: bool) -> &'static str {
172 match (key, value) {
173 (_, true) => "yes",
174 ("ready_to_vote", false) => "not yet",
175 (_, false) => "no",
176 }
177}
178
179pub fn item_title(
183 parent_key: Option<&str>,
184 index: usize,
185 item: &Value,
186) -> String {
187 let field = |k: &str| -> Option<String> {
188 match item.get(k)? {
189 Value::String(s) if !s.is_empty() => Some(s.clone()),
190 Value::Number(n) => Some(n.to_string()),
191 _ => None,
192 }
193 };
194 let with = |head: String, tail: Option<String>| match tail {
195 Some(t) => format!("{head} — {t}"),
196 None => head,
197 };
198 let fallback = || {
200 let key = parent_key.unwrap_or("item");
201 format!(
202 "{} {}",
203 label(key.strip_suffix('s').unwrap_or(key)),
204 index + 1
205 )
206 };
207 match parent_key {
208 Some("rounds") => with(
209 format!(
210 "Round {}",
211 field("number").unwrap_or((index + 1).to_string())
212 ),
213 field("round_type"),
214 ),
215 Some("jury_verdicts") => with(
216 format!(
217 "Juror {}",
218 field("juror_number").unwrap_or((index + 1).to_string())
219 ),
220 field("verdict"),
221 ),
222 Some("responses") => match field("role") {
223 Some(role) => with(label(&role), field("vote")),
224 None => fallback(),
225 },
226 _ => field("name")
227 .or_else(|| field("role"))
228 .unwrap_or_else(fallback),
229 }
230}
231
232pub fn repeats_rationale(obj: &Map<String, Value>, key: &str) -> bool {
237 key == "raw_text"
238 && obj.get(key).is_some_and(Value::is_string)
239 && obj.get("rationale") == obj.get(key)
240}
241
242pub const THINKING_NOTE: &str = "From October 2026 a seat's rationale is its \
245thinking before it answered. On Anthropic models that is a summary of the \
246model's reasoning, written by Anthropic and signed over the full \
247reasoning; Anthropic does not return the reasoning itself. Before then \
248the seats wrote their reasoning into a `reasoning` field of the decision \
249tool, which Anthropic asks developers not to request.";
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum Thought {
254 Summary(String),
256 Own(String),
258 Withheld,
260}
261
262pub fn thinking(key: &str, value: &Value) -> Option<Vec<Thought>> {
264 use misanthropic::prompt::message::Thought as Block;
265 if key != "rationale" || !value.is_array() {
266 return None;
267 }
268 let super::Rationale::Thinking(thoughts) =
269 serde_json::from_value(value.clone()).ok()?
270 else {
271 return None;
272 };
273 Some(
274 thoughts
275 .into_iter()
276 .map(|thought| match thought {
277 Block::Thinking { thought, signature } => {
278 if signature.is_empty() {
279 Thought::Own(thought.into_owned())
280 } else {
281 Thought::Summary(thought.into_owned())
282 }
283 }
284 Block::Redacted { .. } => Thought::Withheld,
285 })
286 .collect(),
287 )
288}
289
290pub fn has_thinking(data: &Value) -> bool {
293 match data {
294 Value::Object(obj) => obj
295 .iter()
296 .any(|(k, v)| thinking(k, v).is_some() || has_thinking(v)),
297 Value::Array(items) => items.iter().any(has_thinking),
298 _ => false,
299 }
300}
301
302pub fn is_token(s: &str) -> bool {
305 s.len() >= 32 && !s.chars().any(char::is_whitespace)
306}
307
308pub fn is_prose(s: &str) -> bool {
310 s.contains('\n') || s.len() > 160
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use serde_json::json;
317
318 #[test]
319 fn key_order_has_no_duplicates() {
320 let mut seen = std::collections::HashSet::new();
321 for key in KEY_ORDER {
322 assert!(seen.insert(key), "{key} listed twice in KEY_ORDER");
323 }
324 }
325
326 fn keys(v: &Value) -> Vec<&str> {
327 ordered(v.as_object().unwrap())
328 .into_iter()
329 .map(|(k, _)| k.as_str())
330 .collect()
331 }
332
333 #[test]
334 fn appeal_reads_in_the_order_it_happened() {
335 let appeal = json!({
337 "outcome": "overturned",
338 "appeal_id": "x",
339 "case_type": "moderation_appeal",
340 "judge_ruling": {},
341 "jury_verdicts": [],
342 "original_action": {},
343 "appeal_statement": "s",
344 "referred_to_council": false,
345 });
346 assert_eq!(
347 keys(&appeal),
348 [
349 "appeal_id",
350 "case_type",
351 "original_action",
352 "appeal_statement",
353 "jury_verdicts",
354 "judge_ruling",
355 "referred_to_council",
356 "outcome",
357 ]
358 );
359 }
360
361 #[test]
362 fn reasoning_comes_before_the_decision() {
363 let juror = json!({
364 "verdict": "overturn",
365 "rationale": "r",
366 "juror_number": 3,
367 "context_analysis": "c",
368 "precedents_cited": [],
369 "constitutional_refs": [],
370 });
371 assert_eq!(
372 keys(&juror),
373 [
374 "juror_number",
375 "context_analysis",
376 "rationale",
377 "constitutional_refs",
378 "precedents_cited",
379 "verdict",
380 ]
381 );
382 let seat = json!({
385 "vote": "yes",
386 "role": "lawyer",
387 "position": "p",
388 "raw_text": "r",
389 "questions": [],
390 "rationale": "r",
391 "ready_to_vote": true,
392 });
393 assert_eq!(
394 keys(&seat),
395 [
396 "role",
397 "rationale",
398 "position",
399 "questions",
400 "ready_to_vote",
401 "vote",
402 "raw_text",
403 ]
404 );
405 }
406
407 #[test]
408 fn unknown_keys_are_kept_before_format_markers() {
409 let v =
410 json!({ "agora_x": 1, "_blind": "b", "zzz_new": 1, "title": "t" });
411 assert_eq!(keys(&v), ["title", "zzz_new", "agora_x", "_blind"]);
412 }
413
414 #[test]
415 fn labels_read_as_words() {
416 assert_eq!(label("appeal_statement"), "Appeal statement");
417 assert_eq!(label("raw_text"), "Raw model output");
418 assert_eq!(label("_blind"), "Redaction blind");
419 assert_eq!(
420 label("agora_governance_amendment"),
421 "Format agora_governance_amendment"
422 );
423 }
424
425 #[test]
426 fn items_are_titled_by_what_they_are() {
427 let round = json!({"number": 2, "round_type": "deliberation"});
428 assert_eq!(
429 item_title(Some("rounds"), 1, &round),
430 "Round 2 — deliberation"
431 );
432 let seat = json!({"role": "lawyer", "vote": "yes"});
433 assert_eq!(item_title(Some("responses"), 0, &seat), "Lawyer — yes");
434 let juror = json!({"verdict": "overturn"});
435 assert_eq!(
436 item_title(Some("jury_verdicts"), 2, &juror),
437 "Juror 3 — overturn"
438 );
439 assert_eq!(
440 item_title(Some("signatures"), 0, &json!({})),
441 "Signature 1"
442 );
443 assert_eq!(item_title(None, 0, &json!({})), "Item 1");
444 }
445
446 #[test]
447 fn thinking_is_read_by_block() {
448 let rationale = json!([
449 {"type": "thinking", "thinking": "Summarized.", "signature": "EqQB"},
450 {"type": "redacted_thinking", "data": "EmwK"},
451 {"type": "thinking", "thinking": "Local.", "signature": ""}
452 ]);
453 assert_eq!(
454 thinking("rationale", &rationale).unwrap(),
455 [
456 Thought::Summary("Summarized.".into()),
457 Thought::Withheld,
458 Thought::Own("Local.".into())
459 ]
460 );
461 assert_eq!(thinking("rationale", &json!("Legacy.")), None);
462 assert_eq!(thinking("position", &rationale), None);
463 assert!(has_thinking(&json!({"rounds": [{"responses": [
464 {"rationale": rationale}
465 ]}]})));
466 assert!(!has_thinking(&json!({"rationale": "Legacy."})));
467 }
468
469 #[test]
470 fn only_an_identical_raw_text_repeats_the_rationale() {
471 let same = json!({"rationale": "r", "raw_text": "r"});
472 let differs = json!({"rationale": "r", "raw_text": "r!"});
473 assert!(repeats_rationale(same.as_object().unwrap(), "raw_text"));
474 assert!(!repeats_rationale(same.as_object().unwrap(), "rationale"));
475 assert!(!repeats_rationale(differs.as_object().unwrap(), "raw_text"));
476 }
477}