1use lc_core::language_models::BaseChatModel;
6use lc_schema::Message;
7
8use super::ResearchError;
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct SubTopic {
13 pub name: String,
15 pub queries: Vec<String>,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct ResearchPlan {
22 pub topic: String,
24 pub subtopics: Vec<SubTopic>,
26}
27
28impl ResearchPlan {
29 pub fn all_queries(&self) -> Vec<String> {
31 self.subtopics
32 .iter()
33 .flat_map(|st| st.queries.clone())
34 .collect()
35 }
36}
37
38pub async fn plan<M: BaseChatModel>(
40 llm: &M,
41 topic: &str,
42 max_subtopics: usize,
43) -> Result<ResearchPlan, ResearchError> {
44 let prompt = format!(
45 "Decompose the following research topic into at most {} sub-topics. \
46 For each sub-topic, generate 1-3 specific search queries.\n\n\
47 IMPORTANT: Ensure sub-topics are non-overlapping and collectively exhaustive (MECE principle). \
48 Each sub-topic should cover a distinct aspect of the topic without redundancy, \
49 and together they should comprehensively cover the entire topic.\n\n\
50 Example:\n\
51 Topic: Impact of AI on healthcare\n\
52 Output:\n\
53 [{{\"name\": \"AI in Diagnostics\", \"queries\": [\"AI diagnostic accuracy healthcare\", \"deep learning medical imaging\"]}}, \
54 {{\"name\": \"AI in Treatment Planning\", \"queries\": [\"AI treatment recommendation systems\"]}}, \
55 {{\"name\": \"Ethics and Regulation\", \"queries\": [\"AI healthcare regulation FDA\", \"medical AI bias ethics\"]}}]\n\n\
56 Topic: {}\n\n\
57 Output a JSON array of objects with \"name\" and \"queries\" fields. \
58 Only output the JSON array, nothing else.",
59 max_subtopics, topic,
60 );
61 let messages = vec![
62 Message::system("You are a research planning assistant. Output only valid JSON."),
63 Message::human(prompt),
64 ];
65
66 let response =
67 crate::retry::retry_chat(llm, messages, None, &crate::retry::RetryConfig::default())
68 .await
69 .map_err(|e| ResearchError::Llm(format!("{:?}", e)))?;
70
71 let subtopics = parse_subtopics(&response.content)?;
72 Ok(ResearchPlan {
73 topic: topic.to_string(),
74 subtopics,
75 })
76}
77
78fn parse_subtopics(content: &str) -> Result<Vec<SubTopic>, ResearchError> {
83 lc_core::json_parse::parse_llm_json::<Vec<SubTopic>>(content)
84 .map_err(|e| ResearchError::Llm(format!("failed to parse sub-topics: {}", e)))
85}
86
87pub fn parse_json_array(content: &str) -> Result<Vec<String>, ResearchError> {
92 lc_core::json_parse::parse_llm_json::<Vec<String>>(content)
93 .map_err(|e| ResearchError::Llm(format!("failed to parse JSON array: {}", e)))
94}
95
96pub fn extract_json(content: &str) -> String {
99 let trimmed = content.trim();
100
101 let stripped = if trimmed.starts_with("```") {
103 trimmed
104 .strip_prefix("```json")
105 .or_else(|| trimmed.strip_prefix("```"))
106 .unwrap_or(trimmed)
107 .strip_suffix("```")
108 .unwrap_or(trimmed)
109 .trim()
110 } else {
111 trimmed
112 };
113
114 let start_bracket = stripped.find(['[', '{']);
116
117 if let Some(start) = start_bracket {
118 let open_char = stripped.as_bytes()[start];
119 let close_char = if open_char == b'[' { b']' } else { b'}' };
120
121 let mut depth = 0i32;
123 let mut in_string = false;
124 let mut escape_next = false;
125 let bytes = stripped.as_bytes();
126
127 for i in start..bytes.len() {
128 let ch = bytes[i];
129 if escape_next {
130 escape_next = false;
131 continue;
132 }
133 if ch == b'\\' {
134 if in_string {
135 escape_next = true;
136 }
137 continue;
138 }
139 if ch == b'"' {
140 in_string = !in_string;
141 continue;
142 }
143 if in_string {
144 continue;
145 }
146 if ch == open_char {
147 depth += 1;
148 } else if ch == close_char {
149 depth -= 1;
150 if depth == 0 {
151 return stripped[start..=i].to_string();
152 }
153 }
154 }
155 }
156
157 stripped.to_string()
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn test_extract_json_plain_array() {
166 let input = r#"[{"name": "A", "queries": ["q1"]}]"#;
167 assert_eq!(extract_json(input), input);
168 }
169
170 #[test]
171 fn test_extract_json_markdown_fenced() {
172 let input = "```json\n[{\"name\": \"A\", \"queries\": [\"q1\"]}]\n```";
173 let expected = r#"[{"name": "A", "queries": ["q1"]}]"#;
174 assert_eq!(extract_json(input), expected);
175 }
176
177 #[test]
178 fn test_extract_json_with_surrounding_text() {
179 let input = r#"Here is the plan: [{"name": "A", "queries": ["q1"]}] Done."#;
180 let expected = r#"[{"name": "A", "queries": ["q1"]}]"#;
181 assert_eq!(extract_json(input), expected);
182 }
183
184 #[test]
185 fn test_extract_json_nested_brackets() {
186 let input = r#"[{"name": "A", "queries": ["q1", "q2"]}, {"name": "B", "queries": ["q3"]}]"#;
187 assert_eq!(extract_json(input), input);
188 }
189
190 #[test]
191 fn test_extract_json_object() {
192 let input = r#"Result: {"report": "text", "gaps": []} end"#;
193 let expected = r#"{"report": "text", "gaps": []}"#;
194 assert_eq!(extract_json(input), expected);
195 }
196
197 #[test]
198 fn test_parse_subtopics_valid() {
199 let content = r#"[{"name": "AI Ethics", "queries": ["AI ethics policy"]}]"#;
200 let subtopics = parse_subtopics(content).unwrap();
201 assert_eq!(subtopics.len(), 1);
202 assert_eq!(subtopics[0].name, "AI Ethics");
203 assert_eq!(subtopics[0].queries, vec!["AI ethics policy"]);
204 }
205
206 #[test]
207 fn test_parse_subtopics_invalid() {
208 let content = "not json at all";
209 let result = parse_subtopics(content);
210 assert!(result.is_err());
211 }
212
213 #[test]
214 fn test_parse_json_array_valid() {
215 let content = r#"["query1", "query2"]"#;
216 let queries = parse_json_array(content).unwrap();
217 assert_eq!(queries, vec!["query1", "query2"]);
218 }
219
220 #[test]
221 fn test_parse_json_array_markdown() {
222 let content = "```json\n[\"q1\", \"q2\"]\n```";
223 let queries = parse_json_array(content).unwrap();
224 assert_eq!(queries, vec!["q1", "q2"]);
225 }
226
227 #[test]
228 fn test_research_plan_all_queries() {
229 let plan = ResearchPlan {
230 topic: "test".to_string(),
231 subtopics: vec![
232 SubTopic {
233 name: "A".to_string(),
234 queries: vec!["q1".to_string(), "q2".to_string()],
235 },
236 SubTopic {
237 name: "B".to_string(),
238 queries: vec!["q3".to_string()],
239 },
240 ],
241 };
242 assert_eq!(plan.all_queries(), vec!["q1", "q2", "q3"]);
243 }
244
245 #[test]
246 fn test_extract_json_with_brackets_in_strings() {
247 let input = r#"[{"name": "A [1]", "queries": ["q1"]}]"#;
248 assert_eq!(extract_json(input), input);
249 }
250
251 #[test]
253 fn test_plan_prompt_contains_mece_and_example() {
254 let max_subtopics = 3;
257 let topic = "Test Topic";
258 let prompt = format!(
259 "Decompose the following research topic into at most {} sub-topics. \
260 For each sub-topic, generate 1-3 specific search queries.\n\n\
261 IMPORTANT: Ensure sub-topics are non-overlapping and collectively exhaustive (MECE principle). \
262 Each sub-topic should cover a distinct aspect of the topic without redundancy, \
263 and together they should comprehensively cover the entire topic.\n\n\
264 Example:\n\
265 Topic: Impact of AI on healthcare\n\
266 Output:\n\
267 [{{\"name\": \"AI in Diagnostics\", \"queries\": [\"AI diagnostic accuracy healthcare\", \"deep learning medical imaging\"]}}, \
268 {{\"name\": \"AI in Treatment Planning\", \"queries\": [\"AI treatment recommendation systems\"]}}, \
269 {{\"name\": \"Ethics and Regulation\", \"queries\": [\"AI healthcare regulation FDA\", \"medical AI bias ethics\"]}}]\n\n\
270 Topic: {}\n\n\
271 Output a JSON array of objects with \"name\" and \"queries\" fields. \
272 Only output the JSON array, nothing else.",
273 max_subtopics, topic,
274 );
275 assert!(
276 prompt.contains("MECE"),
277 "prompt should contain MECE principle"
278 );
279 assert!(
280 prompt.contains("non-overlapping"),
281 "prompt should contain non-overlapping constraint"
282 );
283 assert!(
284 prompt.contains("Example:"),
285 "prompt should contain few-shot example"
286 );
287 assert!(
288 prompt.contains("AI in Diagnostics"),
289 "prompt example should contain sub-topic example"
290 );
291 }
292}