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 })
576 }
577
578 async fn generate_with_tools(
579 &self,
580 _: &str,
581 _: &[ToolDefinition],
582 ) -> Result<LLMResponse> {
583 Ok(LLMResponse {
584 content: String::new(),
585 tool_calls: vec![],
586 finish_reason: "stop".to_string(),
587 usage: None,
588 })
589 }
590
591 async fn generate_with_tools_and_history(
592 &self,
593 _: &[ares_llm::coordinator::ConversationMessage],
594 _: &[ToolDefinition],
595 ) -> Result<LLMResponse> {
596 Ok(LLMResponse {
597 content: String::new(),
598 tool_calls: vec![],
599 finish_reason: "stop".to_string(),
600 usage: None,
601 })
602 }
603
604 async fn stream(
605 &self,
606 _: &str,
607 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
608 Ok(Box::new(futures::stream::empty()))
609 }
610
611 async fn stream_with_system(
612 &self,
613 _: &str,
614 _: &str,
615 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
616 Ok(Box::new(futures::stream::empty()))
617 }
618
619 async fn stream_with_history(
620 &self,
621 _: &[(String, String)],
622 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
623 Ok(Box::new(futures::stream::empty()))
624 }
625 }
626
627
628 fn sample_sub_queries() -> Vec<String> {
629 vec![
630 "What is the core technology?".to_string(),
631 "Who are the main vendors?".to_string(),
632 "What is the market size?".to_string(),
633 ]
634 }
635
636 #[test]
637 fn research_plan_serde_roundtrip() {
638 let plan = plan_research_phases("quantum networking", &sample_sub_queries(), true);
639 let json = serde_json::to_string(&plan).expect("serialize plan");
640 let parsed: ResearchPlan = serde_json::from_str(&json).expect("deserialize plan");
641 assert_eq!(parsed, plan);
642 }
643
644 #[test]
645 fn research_phase_serde_roundtrip() {
646 let phase = ResearchPhase {
647 id: "phase-1".into(),
648 query: "vendors".into(),
649 mode: PhaseExecutionMode::Sequential,
650 depends_on: vec![],
651 };
652 let json = serde_json::to_string(&phase).expect("serialize phase");
653 let parsed: ResearchPhase = serde_json::from_str(&json).expect("deserialize phase");
654 assert_eq!(parsed, phase);
655 }
656
657 #[test]
658 fn research_result_serde_roundtrip() {
659 let result = ResearchResult {
660 findings: vec![ResearchFinding {
661 phase_id: "phase-1".into(),
662 content: "finding".into(),
663 relevance_score: 0.9,
664 }],
665 sources: vec![Source {
666 title: "Paper".into(),
667 url: Some("https://example.edu/paper".into()),
668 relevance_score: 0.9,
669 }],
670 failed_phase_ids: vec!["phase-2".into()],
671 partial: true,
672 };
673 let json = serde_json::to_string(&result).expect("serialize result");
674 let parsed: ResearchResult = serde_json::from_str(&json).expect("deserialize result");
675 assert_eq!(parsed.findings, result.findings);
676 assert_eq!(parsed.failed_phase_ids, result.failed_phase_ids);
677 assert_eq!(parsed.partial, result.partial);
678 assert_eq!(parsed.sources.len(), result.sources.len());
679 }
680
681 #[test]
682 fn phase_execution_mode_serde_roundtrip() {
683 for mode in [PhaseExecutionMode::Sequential, PhaseExecutionMode::Parallel] {
684 let json = serde_json::to_string(&mode).expect("serialize mode");
685 let parsed: PhaseExecutionMode = serde_json::from_str(&json).expect("deserialize mode");
686 assert_eq!(parsed, mode);
687 }
688 }
689
690 #[test]
691 fn plan_research_phases_parallel_has_no_dependencies() {
692 let plan = plan_research_phases("root", &sample_sub_queries(), true);
693 assert!(plan.phases.iter().all(|phase| phase.depends_on.is_empty()));
694 assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Parallel));
695 }
696
697 #[test]
698 fn plan_research_phases_sequential_builds_dependency_chain() {
699 let plan = plan_research_phases("root", &sample_sub_queries(), false);
700 assert_eq!(plan.phases[0].depends_on, Vec::<String>::new());
701 assert_eq!(plan.phases[1].depends_on, vec!["phase-1".to_string()]);
702 assert_eq!(plan.phases[2].depends_on, vec!["phase-2".to_string()]);
703 assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Sequential));
704 }
705
706 #[test]
707 fn plan_research_phases_preserves_sub_queries() {
708 let queries = sample_sub_queries();
709 let plan = plan_research_phases("root", &queries, true);
710 let planned: Vec<_> = plan.phases.iter().map(|phase| phase.query.clone()).collect();
711 assert_eq!(planned, queries);
712 }
713
714 #[test]
715 fn execute_phase_success_scores_relevance() {
716 let phase = ResearchPhase {
717 id: "phase-1".into(),
718 query: "quantum error correction".into(),
719 mode: PhaseExecutionMode::Parallel,
720 depends_on: vec![],
721 };
722 let mut executor = |_: &ResearchPhase| {
723 Ok("quantum error correction advances published:1700000000 https://arxiv.org/abs/123".into())
724 };
725 let finding = execute_phase(&phase, &mut executor).expect("phase should succeed");
726 assert_eq!(finding.phase_id, "phase-1");
727 assert!(finding.relevance_score > 0.5);
728 }
729
730 #[test]
731 fn execute_phase_failure_returns_error() {
732 let phase = ResearchPhase {
733 id: "phase-1".into(),
734 query: "vendors".into(),
735 mode: PhaseExecutionMode::Parallel,
736 depends_on: vec![],
737 };
738 let mut executor = |_: &ResearchPhase| Err("upstream search unavailable".to_string());
739 let err = execute_phase(&phase, &mut executor).expect_err("phase should fail");
740 assert!(err.contains("unavailable"));
741 }
742
743 #[test]
744 fn aggregate_findings_deduplicates_normalized_content() {
745 let findings = vec![
746 ResearchFinding { phase_id: "a".into(), content: "Same content here".into(), relevance_score: 0.4 },
747 ResearchFinding { phase_id: "b".into(), content: "same content here".into(), relevance_score: 0.9 },
748 ];
749 let aggregated = aggregate_findings(&findings);
750 assert_eq!(aggregated.len(), 1);
751 assert_eq!(aggregated[0].phase_id, "b");
752 }
753
754 #[test]
755 fn aggregate_findings_sorts_by_relevance_descending() {
756 let findings = vec![
757 ResearchFinding { phase_id: "low".into(), content: "alpha".into(), relevance_score: 0.2 },
758 ResearchFinding { phase_id: "high".into(), content: "beta".into(), relevance_score: 0.95 },
759 ];
760 let aggregated = aggregate_findings(&findings);
761 assert_eq!(aggregated[0].phase_id, "high");
762 assert_eq!(aggregated[1].phase_id, "low");
763 }
764
765 #[test]
766 fn aggregate_findings_empty_input_returns_empty() {
767 assert!(aggregate_findings(&[]).is_empty());
768 }
769
770 #[test]
771 fn score_content_relevance_handles_no_tokens() {
772 assert_eq!(score_content_relevance("a", "beta gamma delta"), 0.0);
773 }
774
775 #[test]
776 fn rank_sources_prefers_higher_authority() {
777 let now = 1_700_000_000;
778 let candidates = vec![
779 SourceCandidate {
780 source: Source { title: "Blog".into(), url: Some("https://example.com/post".into()), relevance_score: 0.0 },
781 published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.5,
782 },
783 SourceCandidate {
784 source: Source { title: "Government report".into(), url: Some("https://agency.gov/report".into()), relevance_score: 0.0 },
785 published_epoch_secs: Some(now), authority_score: 1.0, content_match_score: 0.5,
786 },
787 ];
788 let ranked = rank_sources(&candidates, "report", now);
789 assert!(ranked[0].url.as_deref().unwrap().contains(".gov"));
790 }
791
792 #[test]
793 fn rank_sources_prefers_more_recent_publication() {
794 let now = 1_700_000_000;
795 let candidates = vec![
796 SourceCandidate {
797 source: Source { title: "Old".into(), url: Some("https://example.com/old".into()), relevance_score: 0.0 },
798 published_epoch_secs: Some(now - 400 * 86_400), authority_score: 0.55, content_match_score: 0.6,
799 },
800 SourceCandidate {
801 source: Source { title: "New".into(), url: Some("https://example.com/new".into()), relevance_score: 0.0 },
802 published_epoch_secs: Some(now - 7 * 86_400), authority_score: 0.55, content_match_score: 0.6,
803 },
804 ];
805 let ranked = rank_sources(&candidates, "example", now);
806 assert_eq!(ranked[0].title, "New");
807 }
808
809 #[test]
810 fn rank_sources_prefers_better_content_match() {
811 let now = 1_700_000_000;
812 let candidates = vec![
813 SourceCandidate {
814 source: Source { title: "Unrelated".into(), url: None, relevance_score: 0.0 },
815 published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.1,
816 },
817 SourceCandidate {
818 source: Source { title: "Quantum networking overview".into(), url: None, relevance_score: 0.0 },
819 published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.95,
820 },
821 ];
822 let ranked = rank_sources(&candidates, "quantum networking", now);
823 assert!(ranked[0].title.contains("Quantum"));
824 }
825
826 #[test]
827 fn run_research_plan_parallel_collects_all_phases() {
828 let plan = plan_research_phases("root", &sample_sub_queries(), true);
829 let mut executor = |phase: &ResearchPhase| Ok(format!("answer for {}", phase.id));
830 let result = run_research_plan(&plan, &mut executor);
831 assert_eq!(result.findings.len(), 3);
832 assert!(!result.partial);
833 assert!(result.failed_phase_ids.is_empty());
834 }
835
836 #[test]
837 fn run_research_plan_sequential_stops_after_failure() {
838 let plan = plan_research_phases("root", &["first".into(), "second".into(), "third".into()], false);
839 let mut executor = |phase: &ResearchPhase| {
840 if phase.id == "phase-2" { Err("phase two failed".into()) } else { Ok(format!("ok {}", phase.id)) }
841 };
842 let result = run_research_plan(&plan, &mut executor);
843 assert_eq!(result.findings.len(), 1);
844 assert!(result.partial);
845 assert_eq!(result.failed_phase_ids, vec!["phase-2".to_string()]);
846 }
847
848 #[test]
849 fn run_research_plan_partial_success_flag() {
850 let plan = plan_research_phases("root", &["only".into()], true);
851 let mut ok = |_: &ResearchPhase| Ok("content".into());
852 assert!(!run_research_plan(&plan, &mut ok).partial);
853 let mut fail = |_: &ResearchPhase| Err("boom".into());
854 let failure = run_research_plan(&plan, &mut fail);
855 assert!(!failure.partial);
856 assert!(failure.findings.is_empty());
857 }
858
859 #[test]
860 fn research_plan_display_includes_phases() {
861 let plan = plan_research_phases("quantum", &["vendors".into()], false);
862 let text = plan.to_string();
863 assert!(text.contains("ResearchPlan: quantum"));
864 assert!(text.contains("phase-1"));
865 }
866
867 #[test]
868 fn research_phase_display_formats_id_and_query() {
869 let phase = ResearchPhase { id: "phase-9".into(), query: "market size".into(), mode: PhaseExecutionMode::Parallel, depends_on: vec![] };
870 let text = phase.to_string();
871 assert!(text.contains("phase-9"));
872 assert!(text.contains("market size"));
873 }
874
875 #[test]
876 fn research_result_display_lists_findings() {
877 let result = ResearchResult {
878 findings: vec![ResearchFinding { phase_id: "phase-1".into(), content: "data".into(), relevance_score: 0.5 }],
879 sources: vec![], failed_phase_ids: vec![], partial: false,
880 };
881 let text = result.to_string();
882 assert!(text.contains("1 finding(s)"));
883 assert!(text.contains("phase-1"));
884 }
885
886 #[test]
887 fn research_plan_clone_and_debug() {
888 let plan = plan_research_phases("root", &["q".into()], true);
889 let cloned = plan.clone();
890 assert_eq!(format!("{plan:?}"), format!("{cloned:?}"));
891 }
892
893 #[test]
894 fn research_usage_debug_clone() {
895 let usage = ResearchUsage { input_tokens: 3, output_tokens: 7 };
896 let cloned = usage.clone();
897 assert_eq!(format!("{usage:?}"), format!("{cloned:?}"));
898 }
899
900 #[test]
901 fn score_authority_boosts_edu_and_gov_domains() {
902 assert_eq!(score_authority(Some("https://university.edu/paper")), 1.0);
903 assert!(score_authority(Some("https://blog.example.com")) < 1.0);
904 }
905
906 #[test]
907 fn findings_to_source_candidates_extracts_url_and_epoch() {
908 let findings = vec![ResearchFinding {
909 phase_id: "phase-1".into(),
910 content: "quantum paper published:1700000000 https://arxiv.org/abs/123".into(),
911 relevance_score: 0.8,
912 }];
913 let candidates = findings_to_source_candidates(&findings, "quantum paper");
914 assert_eq!(candidates[0].source.url.as_deref(), Some("https://arxiv.org/abs/123"));
915 assert_eq!(candidates[0].published_epoch_secs, Some(1_700_000_000));
916 assert!(candidates[0].authority_score >= 0.85);
917 }
918
919 #[tokio::test]
920 async fn test_research_coordinator_end_to_end() {
921 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
922 let (report, sources) = coordinator
923 .research("quantum networking trends")
924 .await
925 .expect("research should succeed");
926
927 assert!(report.contains("Comprehensive synthesized answer"));
928 assert_eq!(sources.len(), 2);
929 assert_eq!(sources[0].title, "Research Finding 1");
930 assert!(sources[0].relevance_score > 0.0);
931 }
932
933 #[tokio::test]
934 async fn test_research_with_usage_accumulates_tokens() {
935 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
936 let (_, _, usage) = coordinator
937 .research_with_usage("market landscape")
938 .await
939 .expect("research_with_usage");
940
941 assert_eq!(usage.input_tokens, 100);
942 assert_eq!(usage.output_tokens, 45);
943 }
944
945 #[tokio::test]
946 async fn test_research_propagates_llm_errors() {
947 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::failing()), 1, 1);
948 let err = coordinator
949 .research("anything")
950 .await
951 .expect_err("expected llm failure");
952 assert!(matches!(err, AppError::Internal(_)));
953 }
954
955 #[test]
956 fn test_token_usage_serde_roundtrip() {
957 let usage = TokenUsage::new(11, 7);
958 let json = serde_json::to_string(&usage).expect("serialize TokenUsage");
959 let parsed: TokenUsage = serde_json::from_str(&json).expect("deserialize TokenUsage");
960 assert_eq!(parsed, usage);
961 }
962
963 #[test]
964 fn test_research_usage_default_is_zero() {
965 let usage = ResearchUsage::default();
966 assert_eq!(usage.input_tokens, 0);
967 assert_eq!(usage.output_tokens, 0);
968 }
969
970 #[tokio::test]
971 async fn test_generate_followup_questions_empty_findings() {
972 let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
973 let (questions, usage) = coordinator
974 .generate_followup_questions("original query", &[])
975 .await
976 .expect("empty findings should succeed");
977 assert!(questions.is_empty());
978 assert!(usage.is_none());
979 }
980}
981