use crate::core::language_models::BaseChatModel;
use crate::schema::Message;
use super::ResearchError;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SubTopic {
pub name: String,
pub queries: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ResearchPlan {
pub topic: String,
pub subtopics: Vec<SubTopic>,
}
impl ResearchPlan {
pub fn all_queries(&self) -> Vec<String> {
self.subtopics
.iter()
.flat_map(|st| st.queries.clone())
.collect()
}
}
pub async fn plan<M: BaseChatModel>(
llm: &M,
topic: &str,
max_subtopics: usize,
) -> Result<ResearchPlan, ResearchError> {
let prompt = format!(
"Decompose the following research topic into at most {} sub-topics. \
For each sub-topic, generate 1-3 specific search queries.\n\n\
Topic: {}\n\n\
Output a JSON array of objects with \"name\" and \"queries\" fields. \
Example: [{{\"name\": \"Sub-topic 1\", \"queries\": [\"query1\", \"query2\"]}}]\n\
Only output the JSON array, nothing else.",
max_subtopics, topic,
);
let messages = vec![
Message::system("You are a research planning assistant. Output only valid JSON."),
Message::human(prompt),
];
let response = llm
.chat(messages, None)
.await
.map_err(|e| ResearchError::Llm(format!("{:?}", e)))?;
let subtopics = parse_subtopics(&response.content)?;
Ok(ResearchPlan {
topic: topic.to_string(),
subtopics,
})
}
fn parse_subtopics(content: &str) -> Result<Vec<SubTopic>, ResearchError> {
let json_str = extract_json(content);
let subtopics: Vec<SubTopic> = serde_json::from_str(&json_str).map_err(|e| {
let preview: String = content.chars().take(200).collect();
ResearchError::Llm(format!(
"failed to parse sub-topics: {} | raw: {}",
e, preview
))
})?;
Ok(subtopics)
}
pub fn parse_json_array(content: &str) -> Result<Vec<String>, ResearchError> {
let json_str = extract_json(content);
serde_json::from_str(&json_str).map_err(|e| {
let preview: String = content.chars().take(200).collect();
ResearchError::Llm(format!(
"failed to parse JSON array: {} | raw: {}",
e, preview
))
})
}
fn extract_json(content: &str) -> String {
let trimmed = content.trim();
let stripped = if trimmed.starts_with("```") {
trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.strip_suffix("```")
.unwrap_or(trimmed)
.trim()
} else {
trimmed
};
let start_bracket = stripped.find(['[', '{']);
if let Some(start) = start_bracket {
let open_char = stripped.as_bytes()[start];
let close_char = if open_char == b'[' { b']' } else { b'}' };
let mut depth = 0i32;
let mut in_string = false;
let mut escape_next = false;
let bytes = stripped.as_bytes();
for i in start..bytes.len() {
let ch = bytes[i];
if escape_next {
escape_next = false;
continue;
}
if ch == b'\\' {
if in_string {
escape_next = true;
}
continue;
}
if ch == b'"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
if ch == open_char {
depth += 1;
} else if ch == close_char {
depth -= 1;
if depth == 0 {
return stripped[start..=i].to_string();
}
}
}
}
stripped.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_json_plain_array() {
let input = r#"[{"name": "A", "queries": ["q1"]}]"#;
assert_eq!(extract_json(input), input);
}
#[test]
fn test_extract_json_markdown_fenced() {
let input = "```json\n[{\"name\": \"A\", \"queries\": [\"q1\"]}]\n```";
let expected = r#"[{"name": "A", "queries": ["q1"]}]"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_with_surrounding_text() {
let input = r#"Here is the plan: [{"name": "A", "queries": ["q1"]}] Done."#;
let expected = r#"[{"name": "A", "queries": ["q1"]}]"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_nested_brackets() {
let input = r#"[{"name": "A", "queries": ["q1", "q2"]}, {"name": "B", "queries": ["q3"]}]"#;
assert_eq!(extract_json(input), input);
}
#[test]
fn test_extract_json_object() {
let input = r#"Result: {"report": "text", "gaps": []} end"#;
let expected = r#"{"report": "text", "gaps": []}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_parse_subtopics_valid() {
let content = r#"[{"name": "AI Ethics", "queries": ["AI ethics policy"]}]"#;
let subtopics = parse_subtopics(content).unwrap();
assert_eq!(subtopics.len(), 1);
assert_eq!(subtopics[0].name, "AI Ethics");
assert_eq!(subtopics[0].queries, vec!["AI ethics policy"]);
}
#[test]
fn test_parse_subtopics_invalid() {
let content = "not json at all";
let result = parse_subtopics(content);
assert!(result.is_err());
}
#[test]
fn test_parse_json_array_valid() {
let content = r#"["query1", "query2"]"#;
let queries = parse_json_array(content).unwrap();
assert_eq!(queries, vec!["query1", "query2"]);
}
#[test]
fn test_parse_json_array_markdown() {
let content = "```json\n[\"q1\", \"q2\"]\n```";
let queries = parse_json_array(content).unwrap();
assert_eq!(queries, vec!["q1", "q2"]);
}
#[test]
fn test_research_plan_all_queries() {
let plan = ResearchPlan {
topic: "test".to_string(),
subtopics: vec![
SubTopic {
name: "A".to_string(),
queries: vec!["q1".to_string(), "q2".to_string()],
},
SubTopic {
name: "B".to_string(),
queries: vec!["q3".to_string()],
},
],
};
assert_eq!(plan.all_queries(), vec!["q1", "q2", "q3"]);
}
#[test]
fn test_extract_json_with_brackets_in_strings() {
let input = r#"[{"name": "A [1]", "queries": ["q1"]}]"#;
assert_eq!(extract_json(input), input);
}
}