1use std::collections::HashSet;
2use std::fmt;
3
4use ares_llm::{client::TokenUsage, LLMClient};
5use ares_types::types::{Result, Source};
6use serde::{Deserialize, Serialize};
7use tokio::task::JoinSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PhaseExecutionMode {
13 Sequential,
14 Parallel,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ResearchPhase {
20 pub id: String,
21 pub query: String,
22 pub mode: PhaseExecutionMode,
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub depends_on: Vec<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ResearchPlan {
30 pub root_query: String,
31 pub phases: Vec<ResearchPhase>,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct ResearchFinding {
37 pub phase_id: String,
38 pub content: String,
39 pub relevance_score: f32,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ResearchResult {
45 pub findings: Vec<ResearchFinding>,
46 pub sources: Vec<Source>,
47 pub failed_phase_ids: Vec<String>,
48 pub partial: bool,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SourceCandidate {
54 pub source: Source,
55 pub published_epoch_secs: Option<u64>,
56 pub authority_score: f32,
57 pub content_match_score: f32,
58}
59
60impl fmt::Display for ResearchPlan {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 writeln!(f, "ResearchPlan: {}", self.root_query)?;
63 for phase in &self.phases {
64 let deps = if phase.depends_on.is_empty() {
65 String::new()
66 } else {
67 format!(" (after {})", phase.depends_on.join(", "))
68 };
69 writeln!(f, " [{} / {:?}] {}{}", phase.id, phase.mode, phase.query, deps)?;
70 }
71 Ok(())
72 }
73}
74
75impl fmt::Display for ResearchPhase {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "ResearchPhase {} [{:?}]: {}", self.id, self.mode, self.query)
78 }
79}
80
81impl fmt::Display for ResearchResult {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 writeln!(f, "ResearchResult: {} finding(s), {} source(s), partial={}", self.findings.len(), self.sources.len(), self.partial)?;
84 if !self.failed_phase_ids.is_empty() {
85 writeln!(f, " failed phases: {}", self.failed_phase_ids.join(", "))?;
86 }
87 for finding in &self.findings {
88 writeln!(f, " - {} (relevance {:.2}): {}", finding.phase_id, finding.relevance_score, finding.content)?;
89 }
90 Ok(())
91 }
92}
93
94pub fn plan_research_phases(root_query: &str, sub_queries: &[String], parallel: bool) -> ResearchPlan {
95 let mode = if parallel { PhaseExecutionMode::Parallel } else { PhaseExecutionMode::Sequential };
96 let phases = sub_queries.iter().enumerate().map(|(index, query)| {
97 let id = format!("phase-{}", index + 1);
98 let depends_on = if parallel || index == 0 { Vec::new() } else { vec![format!("phase-{index}")] };
99 ResearchPhase { id, query: query.clone(), mode, depends_on }
100 }).collect();
101 ResearchPlan { root_query: root_query.to_string(), phases }
102}
103
104pub fn execute_phase<F>(phase: &ResearchPhase, executor: &mut F) -> std::result::Result<ResearchFinding, String>
105where
106 F: FnMut(&ResearchPhase) -> std::result::Result<String, String>,
107{
108 let content = executor(phase)?;
109 Ok(ResearchFinding {
110 phase_id: phase.id.clone(),
111 relevance_score: score_content_relevance(&phase.query, &content),
112 content,
113 })
114}
115
116pub fn run_research_plan<F>(plan: &ResearchPlan, executor: &mut F) -> ResearchResult
117where
118 F: FnMut(&ResearchPhase) -> std::result::Result<String, String>,
119{
120 let mut completed = HashSet::new();
121 let mut raw_findings = Vec::new();
122 let mut failed_phase_ids = Vec::new();
123 loop {
124 let ready: Vec<&ResearchPhase> = plan.phases.iter().filter(|phase| {
125 !completed.contains(&phase.id)
126 && !failed_phase_ids.contains(&phase.id)
127 && phase.depends_on.iter().all(|dep| completed.contains(dep))
128 }).collect();
129 if ready.is_empty() { break; }
130 let parallel = ready[0].mode == PhaseExecutionMode::Parallel;
131 let batch: Vec<&ResearchPhase> = if parallel { ready } else { vec![ready[0]] };
132 for phase in batch {
133 match execute_phase(phase, executor) {
134 Ok(finding) => { completed.insert(phase.id.clone()); raw_findings.push(finding); }
135 Err(_) => { failed_phase_ids.push(phase.id.clone()); }
136 }
137 }
138 }
139 let findings = aggregate_findings(&raw_findings);
140 let candidates = findings_to_source_candidates(&findings, &plan.root_query);
141 let sources = rank_sources(&candidates, &plan.root_query, current_epoch_secs());
142 let partial = !failed_phase_ids.is_empty() && !findings.is_empty();
143
144 ResearchResult {
145 findings,
146 sources,
147 failed_phase_ids,
148 partial,
149 }
150}
151
152pub fn aggregate_findings(findings: &[ResearchFinding]) -> Vec<ResearchFinding> {
153 use std::collections::HashMap;
154
155 let mut by_content: HashMap<String, ResearchFinding> = HashMap::new();
156 for finding in findings {
157 let key = normalize_content(&finding.content);
158 if key.is_empty() {
159 continue;
160 }
161 by_content
162 .entry(key)
163 .and_modify(|existing| {
164 if finding.relevance_score > existing.relevance_score {
165 *existing = finding.clone();
166 }
167 })
168 .or_insert_with(|| finding.clone());
169 }
170
171 let mut unique: Vec<_> = by_content.into_values().collect();
172 unique.sort_by(|left, right| {
173 right
174 .relevance_score
175 .partial_cmp(&left.relevance_score)
176 .unwrap_or(std::cmp::Ordering::Equal)
177 });
178 unique
179}
180
181pub fn rank_sources(candidates: &[SourceCandidate], query: &str, now_epoch_secs: u64) -> Vec<Source> {
182 let mut scored: Vec<(f32, &SourceCandidate)> = candidates.iter().map(|candidate| {
183 let recency = recency_score(candidate.published_epoch_secs, now_epoch_secs);
184 let content = candidate.content_match_score.max(score_content_relevance(query, &candidate.source.title));
185 let composite = 0.45 * candidate.authority_score + 0.30 * recency + 0.25 * content;
186 (composite, candidate)
187 }).collect();
188 scored.sort_by(|(left, _), (right, _)| right.partial_cmp(left).unwrap_or(std::cmp::Ordering::Equal));
189 scored.into_iter().map(|(score, candidate)| {
190 let mut source = candidate.source.clone();
191 source.relevance_score = score.clamp(0.0, 1.0);
192 source
193 }).collect()
194}
195
196pub fn score_content_relevance(query: &str, content: &str) -> f32 {
197 let query_tokens = tokenize(query);
198 if query_tokens.is_empty() { return 0.0; }
199 let content_tokens = tokenize(content);
200 let overlap = query_tokens.iter().filter(|token| content_tokens.contains(*token)).count();
201 (overlap as f32 / query_tokens.len() as f32).clamp(0.0, 1.0)
202}
203
204fn recency_score(published_epoch_secs: Option<u64>, now_epoch_secs: u64) -> f32 {
205 published_epoch_secs.map(|published| {
206 let age_days = now_epoch_secs.saturating_sub(published) / 86_400;
207 (1.0 - (age_days as f32 / 365.0)).clamp(0.0, 1.0)
208 }).unwrap_or(0.5)
209}
210
211fn score_authority(url: Option<&str>) -> f32 {
212 let Some(url) = url else { return 0.3; };
213 let lower = url.to_ascii_lowercase();
214 if lower.contains(".gov") || lower.contains(".edu") { 1.0 }
215 else if lower.contains("arxiv.org") || lower.contains("doi.org") || lower.contains(".org") { 0.85 }
216 else { 0.55 }
217}
218
219fn normalize_content(content: &str) -> String {
220 content.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase()
221}
222
223fn tokenize(text: &str) -> HashSet<String> {
224 text.split(|c: char| !c.is_alphanumeric()).filter(|token| token.len() >= 3).map(|token| token.to_ascii_lowercase()).collect()
225}
226
227fn extract_url(text: &str) -> Option<String> {
228 text.split_whitespace().find(|word| word.starts_with("http://") || word.starts_with("https://")).map(str::to_string)
229}
230
231fn extract_published_epoch(text: &str) -> Option<u64> {
232 text.split_whitespace().find_map(|word| word.strip_prefix("published:").and_then(|value| value.parse().ok()))
233}
234
235fn findings_to_source_candidates(findings: &[ResearchFinding], root_query: &str) -> Vec<SourceCandidate> {
236 findings.iter().enumerate().map(|(index, finding)| {
237 let url = extract_url(&finding.content);
238 let authority_score = score_authority(url.as_deref());
239 let content_match_score = score_content_relevance(root_query, &finding.content).max(finding.relevance_score);
240 SourceCandidate {
241 source: Source { title: format!("Research Finding {}", index + 1), url, relevance_score: content_match_score },
242 published_epoch_secs: extract_published_epoch(&finding.content),
243 authority_score,
244 content_match_score,
245 }
246 }).collect()
247}
248
249fn current_epoch_secs() -> u64 {
250 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|duration| duration.as_secs()).unwrap_or(0)
251}
252
253#[derive(Debug, Clone, Default)]
255pub struct ResearchUsage {
256 pub input_tokens: u32,
257 pub output_tokens: u32,
258}
259
260impl ResearchUsage {
261 fn add(&mut self, usage: Option<&TokenUsage>) {
262 if let Some(usage) = usage {
263 self.input_tokens += usage.prompt_tokens;
264 self.output_tokens += usage.completion_tokens;
265 }
266 }
267}
268
269pub struct ResearchCoordinator {
274 llm: Box<dyn LLMClient>,
275 depth: u8,
276 max_iterations: u8,
277}
278
279impl ResearchCoordinator {
280 pub fn new(llm: Box<dyn LLMClient>, depth: u8, max_iterations: u8) -> Self {
282 Self {
283 llm,
284 depth,
285 max_iterations,
286 }
287 }
288
289 pub async fn research(&self, query: &str) -> Result<(String, Vec<Source>)> {
291 let (synthesis, sources, _) = self.research_with_usage(query).await?;
292 Ok((synthesis, sources))
293 }
294
295 pub async fn research_with_usage(
297 &self,
298 query: &str,
299 ) -> Result<(String, Vec<Source>, ResearchUsage)> {
300 let mut all_findings = Vec::new();
301 let mut usage = ResearchUsage::default();
302
303 let (questions, question_usage) = self.generate_research_questions(query).await?;
305 usage.add(question_usage.as_ref());
306
307 for iteration in 0..self.max_iterations {
309 tracing::info!(
310 "Research iteration {}/{}",
311 iteration + 1,
312 self.max_iterations
313 );
314
315 let findings = self.parallel_research(&questions).await?;
316 all_findings.extend(findings);
317
318 if all_findings.len() >= (self.depth as usize * 3) {
320 break;
321 }
322
323 if iteration < self.max_iterations - 1 {
325 let (follow_ups, followup_usage) = self
326 .generate_followup_questions(query, &all_findings)
327 .await?;
328 usage.add(followup_usage.as_ref());
329
330 if follow_ups.is_empty() {
331 break;
332 }
333 }
334 }
335
336 let (synthesis, synthesis_usage) = self.synthesize_findings(query, &all_findings).await?;
338 usage.add(synthesis_usage.as_ref());
339
340 let all_sources = self.extract_sources(query, &all_findings);
342
343 Ok((synthesis, all_sources, usage))
344 }
345
346 async fn generate_research_questions(
347 &self,
348 query: &str,
349 ) -> Result<(Vec<String>, Option<TokenUsage>)> {
350 let prompt = format!(
351 r#"Generate {} focused research questions to comprehensively answer: {}
352
353Return only the questions, one per line, numbered 1-{}.
354
355Example:
356
3571. [QUESTION 1]
3582. [QUESTION 2]
3593. [QUESTION 3]
360..."#,
361 self.depth, query, self.depth
362 );
363
364 let response = self
365 .llm
366 .generate_with_history(&[("user".to_string(), prompt)])
367 .await?;
368
369 let questions = response
370 .content
371 .lines()
372 .filter(|line| !line.trim().is_empty())
373 .map(|line| {
374 line.trim()
376 .trim_start_matches(|c: char| c.is_numeric() || c == '.' || c == ')')
377 .trim()
378 .to_string()
379 })
380 .collect();
381
382 Ok((questions, response.usage))
383 }
384
385 async fn parallel_research(&self, questions: &[String]) -> Result<Vec<String>> {
386 let mut set = JoinSet::new();
387
388 for question in questions.iter().take(self.depth as usize) {
389 let question = question.clone();
390 let _llm_clone = self.llm.model_name().to_string(); set.spawn(async move {
393 format!("Research findings for: {}", question)
395 });
396 }
397
398 let mut results = Vec::new();
399 while let Some(res) = set.join_next().await {
400 if let Ok(finding) = res {
401 results.push(finding);
402 }
403 }
404
405 Ok(results)
406 }
407
408 async fn generate_followup_questions(
409 &self,
410 _original_query: &str,
411 findings: &[String],
412 ) -> Result<(Vec<String>, Option<TokenUsage>)> {
413 if findings.is_empty() {
414 return Ok((vec![], None));
415 }
416
417 let prompt = format!(
418 r#"Based on these findings:
419 {}
420
421 Generate 2-3 follow-up research questions.
422
423 ONLY output the questions and nothing else, like this:
424
425 <question1>
426 <question2>
427 <question3>
428
429 "#,
430 findings.join("\n")
431 );
432
433 let response = self
434 .llm
435 .generate_with_history(&[("user".to_string(), prompt)])
436 .await?;
437
438 let questions = response
439 .content
440 .lines()
441 .filter(|line| !line.trim().is_empty())
442 .take(3)
443 .map(|s| s.to_string())
444 .collect();
445
446 Ok((questions, response.usage))
447 }
448
449 async fn synthesize_findings(
450 &self,
451 query: &str,
452 findings: &[String],
453 ) -> Result<(String, Option<TokenUsage>)> {
454 let prompt = format!(
455 r#"Original query: {}
456
457 Research findings:
458 {}
459
460 Synthesize these findings into a comprehensive, well-structured answer. Include:
461 1. Direct answer to the question
462 2. Key insights
463 3. Supporting evidence
464 4. Caveats or limitations if any
465
466 Provide a clear, professional response."#,
467 query,
468 findings.join("\n\n")
469 );
470
471 let response = self
472 .llm
473 .generate_with_history(&[("user".to_string(), prompt)])
474 .await?;
475 Ok((response.content, response.usage))
476 }
477
478 fn extract_sources(&self, query: &str, findings: &[String]) -> Vec<Source> {
479 let research_findings: Vec<ResearchFinding> = findings
480 .iter()
481 .enumerate()
482 .map(|(index, finding)| ResearchFinding {
483 phase_id: format!("finding-{}", index + 1),
484 content: finding.clone(),
485 relevance_score: score_content_relevance(query, finding),
486 })
487 .collect();
488
489 let aggregated = aggregate_findings(&research_findings);
490 let candidates = findings_to_source_candidates(&aggregated, query);
491 rank_sources(&candidates, query, current_epoch_secs())
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use ares_llm::{client::TokenUsage, LLMClient, LLMResponse};
499 use ares_types::types::{AppError, ToolDefinition};
500 use async_trait::async_trait;
501 use std::sync::atomic::{AtomicUsize, Ordering};
502 use std::sync::Arc;
503
504 struct ResearchMockLlm {
505 calls: Arc<AtomicUsize>,
506 fail: bool,
507 }
508
509 impl ResearchMockLlm {
510 fn new() -> Self {
511 Self {
512 calls: Arc::new(AtomicUsize::new(0)),
513 fail: false,
514 }
515 }
516
517 fn failing() -> Self {
518 Self {
519 calls: Arc::new(AtomicUsize::new(0)),
520 fail: true,
521 }
522 }
523 }
524
525 #[async_trait]
526 impl LLMClient for ResearchMockLlm {
527 fn model_name(&self) -> &str {
528 "research-mock"
529 }
530
531 async fn generate(&self, _: &str) -> Result<String> {
532 Ok(String::new())
533 }
534
535 async fn generate_with_system(&self, _: &str, _: &str) -> Result<String> {
536 Ok(String::new())
537 }
538
539 async fn generate_with_history(
540 &self,
541 messages: &[(String, String)],
542 ) -> Result<LLMResponse> {
543 if self.fail {
544 return Err(AppError::Internal("mock llm failure".into()));
545 }
546
547 let _n = self.calls.fetch_add(1, Ordering::SeqCst);
548 let prompt = messages
549 .last()
550 .map(|(_, content)| content.as_str())
551 .unwrap_or_default();
552
553 let (content, usage) = if prompt.contains("follow-up research questions") {
554 (
555 "What is the regulatory timeline?\nWhat are adoption barriers?".to_string(),
556 TokenUsage::new(30, 12),
557 )
558 } else if prompt.contains("Synthesize these findings") {
559 (
560 "Comprehensive synthesized answer.".to_string(),
561 TokenUsage::new(50, 25),
562 )
563 } else {
564 (
565 "1. What is the core technology?\n2. Who are the main vendors?".to_string(),
566 TokenUsage::new(20, 8),
567 )
568 };
569
570 Ok(LLMResponse {
571 content,
572 tool_calls: vec![],
573 finish_reason: "stop".to_string(),
574 usage: Some(usage),
575 reasoning_content: None,
576 response_id: None,
577 })
578 }
579
580 async fn generate_with_tools(
581 &self,
582 _: &str,
583 _: &[ToolDefinition],
584 ) -> Result<LLMResponse> {
585 Ok(LLMResponse {
586 content: String::new(),
587 tool_calls: vec![],
588 finish_reason: "stop".to_string(),
589 usage: None,
590 reasoning_content: None,
591 response_id: None,
592 })
593 }
594
595 async fn generate_with_tools_and_history(
596 &self,
597 _: &[ares_llm::coordinator::ConversationMessage],
598 _: &[ToolDefinition],
599 ) -> Result<LLMResponse> {
600 Ok(LLMResponse {
601 content: String::new(),
602 tool_calls: vec![],
603 finish_reason: "stop".to_string(),
604 usage: None,
605 reasoning_content: None,
606 response_id: None,
607 })
608 }
609
610 async fn stream(
611 &self,
612 _: &str,
613 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
614 Ok(Box::new(futures::stream::empty()))
615 }
616
617 async fn stream_with_system(
618 &self,
619 _: &str,
620 _: &str,
621 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
622 Ok(Box::new(futures::stream::empty()))
623 }
624
625 async fn stream_with_history(
626 &self,
627 _: &[(String, String)],
628 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
629 Ok(Box::new(futures::stream::empty()))
630 }
631 }
632
633
634 fn sample_sub_queries() -> Vec<String> {
635 vec![
636 "What is the core technology?".to_string(),
637 "Who are the main vendors?".to_string(),
638 "What is the market size?".to_string(),
639 ]
640 }
641
642 #[test]
643 fn research_plan_serde_roundtrip() {
644 let plan = plan_research_phases("quantum networking", &sample_sub_queries(), true);
645 let json = serde_json::to_string(&plan).expect("serialize plan");
646 let parsed: ResearchPlan = serde_json::from_str(&json).expect("deserialize plan");
647 assert_eq!(parsed, plan);
648 }
649
650 #[test]
651 fn research_phase_serde_roundtrip() {
652 let phase = ResearchPhase {
653 id: "phase-1".into(),
654 query: "vendors".into(),
655 mode: PhaseExecutionMode::Sequential,
656 depends_on: vec![],
657 };
658 let json = serde_json::to_string(&phase).expect("serialize phase");
659 let parsed: ResearchPhase = serde_json::from_str(&json).expect("deserialize phase");
660 assert_eq!(parsed, phase);
661 }
662
663 #[test]
664 fn research_result_serde_roundtrip() {
665 let result = ResearchResult {
666 findings: vec![ResearchFinding {
667 phase_id: "phase-1".into(),
668 content: "finding".into(),
669 relevance_score: 0.9,
670 }],
671 sources: vec![Source {
672 title: "Paper".into(),
673 url: Some("https://example.edu/paper".into()),
674 relevance_score: 0.9,
675 }],
676 failed_phase_ids: vec!["phase-2".into()],
677 partial: true,
678 };
679 let json = serde_json::to_string(&result).expect("serialize result");
680 let parsed: ResearchResult = serde_json::from_str(&json).expect("deserialize result");
681 assert_eq!(parsed.findings, result.findings);
682 assert_eq!(parsed.failed_phase_ids, result.failed_phase_ids);
683 assert_eq!(parsed.partial, result.partial);
684 assert_eq!(parsed.sources.len(), result.sources.len());
685 }
686
687 #[test]
688 fn phase_execution_mode_serde_roundtrip() {
689 for mode in [PhaseExecutionMode::Sequential, PhaseExecutionMode::Parallel] {
690 let json = serde_json::to_string(&mode).expect("serialize mode");
691 let parsed: PhaseExecutionMode = serde_json::from_str(&json).expect("deserialize mode");
692 assert_eq!(parsed, mode);
693 }
694 }
695
696 #[test]
697 fn plan_research_phases_parallel_has_no_dependencies() {
698 let plan = plan_research_phases("root", &sample_sub_queries(), true);
699 assert!(plan.phases.iter().all(|phase| phase.depends_on.is_empty()));
700 assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Parallel));
701 }
702
703 #[test]
704 fn plan_research_phases_sequential_builds_dependency_chain() {
705 let plan = plan_research_phases("root", &sample_sub_queries(), false);
706 assert_eq!(plan.phases[0].depends_on, Vec::<String>::new());
707 assert_eq!(plan.phases[1].depends_on, vec!["phase-1".to_string()]);
708 assert_eq!(plan.phases[2].depends_on, vec!["phase-2".to_string()]);
709 assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Sequential));
710 }
711
712 #[test]
713 fn plan_research_phases_preserves_sub_queries() {
714 let queries = sample_sub_queries();
715 let plan = plan_research_phases("root", &queries, true);
716 let planned: Vec<_> = plan.phases.iter().map(|phase| phase.query.clone()).collect();
717 assert_eq!(planned, queries);
718 }
719
720 #[test]
721 fn execute_phase_success_scores_relevance() {
722 let phase = ResearchPhase {
723 id: "phase-1".into(),
724 query: "quantum error correction".into(),
725 mode: PhaseExecutionMode::Parallel,
726 depends_on: vec![],
727 };
728 let mut executor = |_: &ResearchPhase| {
729 Ok("quantum error correction advances published:1700000000 https://arxiv.org/abs/123".into())
730 };
731 let finding = execute_phase(&phase, &mut executor).expect("phase should succeed");
732 assert_eq!(finding.phase_id, "phase-1");
733 assert!(finding.relevance_score > 0.5);
734 }
735
736 #[test]
737 fn execute_phase_failure_returns_error() {
738 let phase = ResearchPhase {
739 id: "phase-1".into(),
740 query: "vendors".into(),
741 mode: PhaseExecutionMode::Parallel,
742 depends_on: vec![],
743 };
744 let mut executor = |_: &ResearchPhase| Err("upstream search unavailable".to_string());
745 let err = execute_phase(&phase, &mut executor).expect_err("phase should fail");
746 assert!(err.contains("unavailable"));
747 }
748
749 #[test]
750 fn aggregate_findings_deduplicates_normalized_content() {
751 let findings = vec![
752 ResearchFinding { phase_id: "a".into(), content: "Same content here".into(), relevance_score: 0.4 },
753 ResearchFinding { phase_id: "b".into(), content: "same content here".into(), relevance_score: 0.9 },
754 ];
755 let aggregated = aggregate_findings(&findings);
756 assert_eq!(aggregated.len(), 1);
757 assert_eq!(aggregated[0].phase_id, "b");
758 }
759
760 #[test]
761 fn aggregate_findings_sorts_by_relevance_descending() {
762 let findings = vec![
763 ResearchFinding { phase_id: "low".into(), content: "alpha".into(), relevance_score: 0.2 },
764 ResearchFinding { phase_id: "high".into(), content: "beta".into(), relevance_score: 0.95 },
765 ];
766 let aggregated = aggregate_findings(&findings);
767 assert_eq!(aggregated[0].phase_id, "high");
768 assert_eq!(aggregated[1].phase_id, "low");
769 }
770
771 #[test]
772 fn aggregate_findings_empty_input_returns_empty() {
773 assert!(aggregate_findings(&[]).is_empty());
774 }
775
776 #[test]
777 fn score_content_relevance_handles_no_tokens() {
778 assert_eq!(score_content_relevance("a", "beta gamma delta"), 0.0);
779 }
780
781 #[test]
782 fn rank_sources_prefers_higher_authority() {
783 let now = 1_700_000_000;
784 let candidates = vec![
785 SourceCandidate {
786 source: Source { title: "Blog".into(), url: Some("https://example.com/post".into()), relevance_score: 0.0 },
787 published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.5,
788 },
789 SourceCandidate {
790 source: Source { title: "Government report".into(), url: Some("https://agency.gov/report".into()), relevance_score: 0.0 },
791 published_epoch_secs: Some(now), authority_score: 1.0, content_match_score: 0.5,
792 },
793 ];
794 let ranked = rank_sources(&candidates, "report", now);
795 assert!(ranked[0].url.as_deref().unwrap().contains(".gov"));
796 }
797
798 #[test]
799 fn rank_sources_prefers_more_recent_publication() {
800 let now = 1_700_000_000;
801 let candidates = vec![
802 SourceCandidate {
803 source: Source { title: "Old".into(), url: Some("https://example.com/old".into()), relevance_score: 0.0 },
804 published_epoch_secs: Some(now - 400 * 86_400), authority_score: 0.55, content_match_score: 0.6,
805 },
806 SourceCandidate {
807 source: Source { title: "New".into(), url: Some("https://example.com/new".into()), relevance_score: 0.0 },
808 published_epoch_secs: Some(now - 7 * 86_400), authority_score: 0.55, content_match_score: 0.6,
809 },
810 ];
811 let ranked = rank_sources(&candidates, "example", now);
812 assert_eq!(ranked[0].title, "New");
813 }
814
815 #[test]
816 fn rank_sources_prefers_better_content_match() {
817 let now = 1_700_000_000;
818 let candidates = vec![
819 SourceCandidate {
820 source: Source { title: "Unrelated".into(), url: None, relevance_score: 0.0 },
821 published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.1,
822 },
823 SourceCandidate {
824 source: Source { title: "Quantum networking overview".into(), url: None, relevance_score: 0.0 },
825 published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.95,
826 },
827 ];
828 let ranked = rank_sources(&candidates, "quantum networking", now);
829 assert!(ranked[0].title.contains("Quantum"));
830 }
831
832 #[test]
833 fn run_research_plan_parallel_collects_all_phases() {
834 let plan = plan_research_phases("root", &sample_sub_queries(), true);
835 let mut executor = |phase: &ResearchPhase| Ok(format!("answer for {}", phase.id));
836 let result = run_research_plan(&plan, &mut executor);
837 assert_eq!(result.findings.len(), 3);
838 assert!(!result.partial);
839 assert!(result.failed_phase_ids.is_empty());
840 }
841
842 #[test]
843 fn run_research_plan_sequential_stops_after_failure() {
844 let plan = plan_research_phases("root", &["first".into(), "second".into(), "third".into()], false);
845 let mut executor = |phase: &ResearchPhase| {
846 if phase.id == "phase-2" { Err("phase two failed".into()) } else { Ok(format!("ok {}", phase.id)) }
847 };
848 let result = run_research_plan(&plan, &mut executor);
849 assert_eq!(result.findings.len(), 1);
850 assert!(result.partial);
851 assert_eq!(result.failed_phase_ids, vec!["phase-2".to_string()]);
852 }
853
854 #[test]
855 fn run_research_plan_partial_success_flag() {
856 let plan = plan_research_phases("root", &["only".into()], true);
857 let mut ok = |_: &ResearchPhase| Ok("content".into());
858 assert!(!run_research_plan(&plan, &mut ok).partial);
859 let mut fail = |_: &ResearchPhase| Err("boom".into());
860 let failure = run_research_plan(&plan, &mut fail);
861 assert!(!failure.partial);
862 assert!(failure.findings.is_empty());
863 }
864
865 #[test]
866 fn research_plan_display_includes_phases() {
867 let plan = plan_research_phases("quantum", &["vendors".into()], false);
868 let text = plan.to_string();
869 assert!(text.contains("ResearchPlan: quantum"));
870 assert!(text.contains("phase-1"));
871 }
872
873 #[test]
874 fn research_phase_display_formats_id_and_query() {
875 let phase = ResearchPhase { id: "phase-9".into(), query: "market size".into(), mode: PhaseExecutionMode::Parallel, depends_on: vec![] };
876 let text = phase.to_string();
877 assert!(text.contains("phase-9"));
878 assert!(text.contains("market size"));
879 }
880
881 #[test]
882 fn research_result_display_lists_findings() {
883 let result = ResearchResult {
884 findings: vec![ResearchFinding { phase_id: "phase-1".into(), content: "data".into(), relevance_score: 0.5 }],
885 sources: vec![], failed_phase_ids: vec![], partial: false,
886 };
887 let text = result.to_string();
888 assert!(text.contains("1 finding(s)"));
889 assert!(text.contains("phase-1"));
890 }
891
892 #[test]
893 fn research_plan_clone_and_debug() {
894 let plan = plan_research_phases("root", &["q".into()], true);
895 let cloned = plan.clone();
896 assert_eq!(format!("{plan:?}"), format!("{cloned:?}"));
897 }
898
899 #[test]
900 fn research_usage_debug_clone() {
901 let usage = ResearchUsage { input_tokens: 3, output_tokens: 7 };
902 let cloned = usage.clone();
903 assert_eq!(format!("{usage:?}"), format!("{cloned:?}"));
904 }
905
906 #[test]
907 fn score_authority_boosts_edu_and_gov_domains() {
908 assert_eq!(score_authority(Some("https://university.edu/paper")), 1.0);
909 assert!(score_authority(Some("https://blog.example.com")) < 1.0);
910 }
911
912 #[test]
913 fn findings_to_source_candidates_extracts_url_and_epoch() {
914 let findings = vec![ResearchFinding {
915 phase_id: "phase-1".into(),
916 content: "quantum paper published:1700000000 https://arxiv.org/abs/123".into(),
917 relevance_score: 0.8,
918 }];
919 let candidates = findings_to_source_candidates(&findings, "quantum paper");
920 assert_eq!(candidates[0].source.url.as_deref(), Some("https://arxiv.org/abs/123"));
921 assert_eq!(candidates[0].published_epoch_secs, Some(1_700_000_000));
922 assert!(candidates[0].authority_score >= 0.85);
923 }
924
925 #[tokio::test]
926 async fn test_research_coordinator_end_to_end() {
927 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
928 let (report, sources) = coordinator
929 .research("quantum networking trends")
930 .await
931 .expect("research should succeed");
932
933 assert!(report.contains("Comprehensive synthesized answer"));
934 assert_eq!(sources.len(), 2);
935 assert_eq!(sources[0].title, "Research Finding 1");
936 assert!(sources[0].relevance_score > 0.0);
937 }
938
939 #[tokio::test]
940 async fn test_research_with_usage_accumulates_tokens() {
941 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
942 let (_, _, usage) = coordinator
943 .research_with_usage("market landscape")
944 .await
945 .expect("research_with_usage");
946
947 assert_eq!(usage.input_tokens, 100);
948 assert_eq!(usage.output_tokens, 45);
949 }
950
951 #[tokio::test]
952 async fn test_research_propagates_llm_errors() {
953 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::failing()), 1, 1);
954 let err = coordinator
955 .research("anything")
956 .await
957 .expect_err("expected llm failure");
958 assert!(matches!(err, AppError::Internal(_)));
959 }
960
961 #[test]
962 fn test_token_usage_serde_roundtrip() {
963 let usage = TokenUsage::new(11, 7);
964 let json = serde_json::to_string(&usage).expect("serialize TokenUsage");
965 let parsed: TokenUsage = serde_json::from_str(&json).expect("deserialize TokenUsage");
966 assert_eq!(parsed, usage);
967 }
968
969 #[test]
970 fn test_research_usage_default_is_zero() {
971 let usage = ResearchUsage::default();
972 assert_eq!(usage.input_tokens, 0);
973 assert_eq!(usage.output_tokens, 0);
974 }
975
976 #[tokio::test]
977 async fn test_generate_followup_questions_empty_findings() {
978 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
979 let (questions, usage) = coordinator
980 .generate_followup_questions("original query", &[])
981 .await
982 .expect("empty findings should succeed");
983 assert!(questions.is_empty());
984 assert!(usage.is_none());
985 }
986}
987