use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CritiqueItem {
#[serde(default)]
pub aspect: String,
#[serde(default)]
pub issue: String,
#[serde(default)]
pub recommendation: String,
#[serde(default)]
pub severity: String,
}
impl CritiqueItem {
pub fn is_usable(&self) -> bool {
!self.issue.trim().is_empty() || !self.recommendation.trim().is_empty()
}
pub fn severity_rank(&self) -> u8 {
match self.severity.trim().to_lowercase().as_str() {
"high" => 0,
"low" => 2,
_ => 1,
}
}
}
pub fn critique_system(lang: &str) -> String {
format!(
"You are a rigorous worldbuilding reviewer. You are given a fantasy/SF world's \
declared definition (HJSON) and a summary of what it deterministically compiles to \
(its astronomy, geology, climate, hydrology, demographics, peoples, and magic). \
Your job is to find CONSISTENCY problems (a declared value that contradicts its \
compiled consequence), PHYSICAL IMPLAUSIBILITIES (an axial tilt, star class, sea \
level, or population that would not produce the described world), and MISSED \
OPPORTUNITIES to make the world more realistic. \
Respond with ONLY a JSON array (no prose, no code fences) of objects with keys: \
\"aspect\" (the world facet), \"issue\" (what is off, citing the declared value), \
\"recommendation\" (a concrete, actionable fix or deepening), and \"severity\" \
(one of high, medium, low). Be specific and grounded in the numbers given; do not \
invent facts not implied by the input. If the world is sound, return []. \
Write the \"issue\" and \"recommendation\" text in {lang}."
)
}
pub fn build_critique_prompt(hjson: &str, compiled_summary: &str) -> String {
const MAX_HJSON: usize = 8_000;
let decl = if hjson.len() > MAX_HJSON {
format!("{}\n… (truncated)", &hjson[..MAX_HJSON])
} else {
hjson.to_string()
};
format!(
"=== DECLARED world.hjson ===\n{decl}\n\n=== COMPILED consequences ===\n{compiled_summary}\n\n\
Return the JSON array of findings now."
)
}
pub fn parse_critique(raw: &str) -> Vec<CritiqueItem> {
let Some(json) = extract_json_array(raw) else {
return Vec::new();
};
let mut items: Vec<CritiqueItem> = serde_json::from_str(&json).unwrap_or_default();
items.retain(|i| i.is_usable());
items.sort_by_key(|i| i.severity_rank());
items
}
fn extract_json_array(raw: &str) -> Option<String> {
let bytes = raw.as_bytes();
let start = raw.find('[')?;
let mut depth = 0i32;
let mut in_str = false;
let mut esc = false;
for i in start..bytes.len() {
let c = bytes[i] as char;
if in_str {
if esc {
esc = false;
} else if c == '\\' {
esc = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => in_str = true,
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(raw[start..=i].to_string());
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_fenced_json_array_and_sorts_by_severity() {
let raw = r#"Here are the findings:
```json
[
{"aspect":"climate","issue":"minor","recommendation":"tweak","severity":"low"},
{"aspect":"astronomy","issue":"tilt 89° would give extreme seasons","recommendation":"lower it","severity":"high"}
]
```
Hope this helps."#;
let items = parse_critique(raw);
assert_eq!(items.len(), 2);
assert_eq!(items[0].severity, "high"); assert_eq!(items[0].aspect, "astronomy");
}
#[test]
fn drops_empty_items_and_tolerates_junk() {
let raw = r#"[{"aspect":"x","issue":"","recommendation":""},{"aspect":"geology","issue":"real","recommendation":"do"}]"#;
let items = parse_critique(raw);
assert_eq!(items.len(), 1);
assert_eq!(items[0].aspect, "geology");
assert!(parse_critique("no json here").is_empty());
assert!(parse_critique("").is_empty());
}
#[test]
fn nested_objects_and_strings_with_brackets_survive() {
let raw = r#"[{"aspect":"magic","issue":"a rule mentions [brackets]","recommendation":"fine","severity":"medium"}]"#;
let items = parse_critique(raw);
assert_eq!(items.len(), 1);
assert!(items[0].issue.contains("[brackets]"));
}
}