Muhammad Usman commited on
Commit
0e23fcc
·
1 Parent(s): 8386c9c

fix(phase-2): resolve console streaming, fallback visibility, and score-based dedup

Browse files

- Fixed live colored console stream handler in logger.py
- Added visible fallback alert banner in main.py and is_fallback tracking in PlanOutput
- Updated .env.example with MAX_SUB_QUERIES and planner hyperparameters
- Enhanced PlannerAgent JSON extraction for reasoning and prompt robustness
- Fixed limit=1 prompt wording edge case in planner_agent.py
- Implemented highest-score retention for URL deduplication in search_agent.py
- Synchronized README.md project structure and telemetry tables

.env.example CHANGED
@@ -1,11 +1,17 @@
1
  # Groq API Configuration (Free tier: https://console.groq.com/)
2
  GROQ_API_KEY=your_groq_api_key_here
3
  GROQ_MODEL=openai/gpt-oss-20b
4
- MAX_TOKENS=1024
5
 
6
  # Tavily API Configuration (Free 1,000 searches/mo: https://tavily.com/)
7
  TAVILY_API_KEY=your_tavily_api_key_here
8
 
9
  # Pipeline Settings
10
  MAX_SEARCH_RESULTS=5
 
 
 
 
11
  TEMPERATURE=0.2
 
 
 
 
1
  # Groq API Configuration (Free tier: https://console.groq.com/)
2
  GROQ_API_KEY=your_groq_api_key_here
3
  GROQ_MODEL=openai/gpt-oss-20b
 
4
 
5
  # Tavily API Configuration (Free 1,000 searches/mo: https://tavily.com/)
6
  TAVILY_API_KEY=your_tavily_api_key_here
7
 
8
  # Pipeline Settings
9
  MAX_SEARCH_RESULTS=5
10
+ MAX_SUB_QUERIES=3
11
+ MAX_RESULTS_PER_SUBQUERY=3
12
+
13
+ # Model Hyperparameters
14
  TEMPERATURE=0.2
15
+ MAX_TOKENS=1024
16
+ PLANNER_TEMPERATURE=0.1
17
+ PLANNER_MAX_TOKENS=500
README.md CHANGED
@@ -69,14 +69,15 @@ Traditional LLMs hallucinate or rely on frozen training data. Unlike standard st
69
 
70
  ---
71
 
72
- ## 📊 Benchmarks & Telemetry (Phase 1 Baseline)
73
 
74
- | Metric | Measured Value | Target | Status |
75
- | :--- | :--- | :--- | :--- |
76
- | **Search Latency (Tavily)** | ~0.95s – 4.0s | < 10.0s | ✅ Optimal |
77
- | **Synthesis Latency (Groq)** | ~2.1s – 3.2s | < 10.0s | ✅ Optimal |
78
- | **Total End-to-End Latency** | **~3.15s – 7.97s** | **< 30.0s** | ✅ **Well within target** |
79
- | **Cost Per Query** | **$0.00** (Free Developer Tier) | $0.00 | ✅ 100% Free |
 
80
 
81
  ---
82
 
@@ -85,22 +86,24 @@ Traditional LLMs hallucinate or rely on frozen training data. Unlike standard st
85
  ```
86
  Multi-Agent-Research-Assistant/
87
  ├── .gitignore # Protection for secrets, logs, and venvs
88
- ├── .env.example # Configuration template
89
  ├── requirements.txt # Exact-pinned dependencies
90
- ├── main.py # CLI entry point
91
  ├── logs/ # Runtime logs (gitignored)
92
  │ └── research_assistant.log
93
  ├── src/
94
  │ ├── __init__.py
95
- │ ├── config.py # Settings validation & loader
96
- │ ├── logger.py # Structured logging setup
97
- │ ├── pipeline.py # Two-Agent Pipeline coordinator
98
  │ └── agents/
99
  │ ├── __init__.py
100
- │ ├── search_agent.py # Tavily Web Search agent
 
101
  │ └── writer_agent.py # Groq Synthesis & Grounding agent
102
  └── tests/
103
- └── test_phase1.py # Automated unit & mock tests
 
104
  ```
105
 
106
  ---
@@ -137,12 +140,23 @@ cp .env.example .env
137
  ```
138
  Edit `.env`:
139
  ```env
 
140
  GROQ_API_KEY=gsk_your_groq_api_key_here
141
  GROQ_MODEL=openai/gpt-oss-20b
 
 
142
  TAVILY_API_KEY=tvly-your_tavily_api_key_here
 
 
143
  MAX_SEARCH_RESULTS=5
 
 
 
 
144
  TEMPERATURE=0.2
145
  MAX_TOKENS=1024
 
 
146
  ```
147
 
148
  ### 4. Run the Research Assistant
 
69
 
70
  ---
71
 
72
+ ## 📊 Benchmarks & Telemetry
73
 
74
+ | Metric | Phase 1 (Single-Search) | Phase 2 (Planner + Multi-Search) | Target | Status |
75
+ | :--- | :--- | :--- | :--- | :--- |
76
+ | **Planning Latency (Groq)** | N/A | ~0.8s – 1.2s | < 3.0s | ✅ Sub-second |
77
+ | **Search Latency (Tavily)** | ~0.95s – 4.0s | ~8.0s – 15.0s (3 sub-queries) | < 20.0s | ✅ Optimal |
78
+ | **Synthesis Latency (Groq)** | ~2.1s – 3.2s | ~1.7s – 3.5s | < 10.0s | ✅ Optimal |
79
+ | **Total End-to-End Latency** | **~3.15s – 7.97s** | **~10.5s – 18.0s** | **< 30.0s** | ✅ **Well within target** |
80
+ | **Cost Per Query** | **$0.00** (Free Tier) | **$0.00** (Free Tier) | $0.00 | ✅ 100% Free |
81
 
82
  ---
83
 
 
86
  ```
87
  Multi-Agent-Research-Assistant/
88
  ├── .gitignore # Protection for secrets, logs, and venvs
89
+ ├── .env.example # Configuration template (includes Phase 2 settings)
90
  ├── requirements.txt # Exact-pinned dependencies
91
+ ├── main.py # Interactive CLI entry point with fallback alerts
92
  ├── logs/ # Runtime logs (gitignored)
93
  │ └── research_assistant.log
94
  ├── src/
95
  │ ├── __init__.py
96
+ │ ├── config.py # Settings validation & dynamic configuration
97
+ │ ├── logger.py # Centralized logging (live console streaming + file logging)
98
+ │ ├── pipeline.py # Three-Agent Pipeline coordinator (Planner -> Multi-Search -> Writer)
99
  │ └── agents/
100
  │ ├── __init__.py
101
+ │ ├── planner_agent.py # Query decomposition & JSON planning agent
102
+ │ ├── search_agent.py # Tavily Web Search & score-based URL deduplication
103
  │ └── writer_agent.py # Groq Synthesis & Grounding agent
104
  └── tests/
105
+ ├── test_phase1.py # Phase 1 unit & pipeline tests
106
+ └── test_phase2.py # Phase 2 planner, deduplication & 3-agent tests
107
  ```
108
 
109
  ---
 
140
  ```
141
  Edit `.env`:
142
  ```env
143
+ # Groq API Configuration (Free tier: https://console.groq.com/)
144
  GROQ_API_KEY=gsk_your_groq_api_key_here
145
  GROQ_MODEL=openai/gpt-oss-20b
146
+
147
+ # Tavily API Configuration (Free 1,000 searches/mo: https://tavily.com/)
148
  TAVILY_API_KEY=tvly-your_tavily_api_key_here
149
+
150
+ # Pipeline Settings
151
  MAX_SEARCH_RESULTS=5
152
+ MAX_SUB_QUERIES=3
153
+ MAX_RESULTS_PER_SUBQUERY=3
154
+
155
+ # Model Hyperparameters
156
  TEMPERATURE=0.2
157
  MAX_TOKENS=1024
158
+ PLANNER_TEMPERATURE=0.1
159
+ PLANNER_MAX_TOKENS=500
160
  ```
161
 
162
  ### 4. Run the Research Assistant
main.py CHANGED
@@ -34,13 +34,16 @@ def run_pipeline(query: str):
34
  result = pipeline.run(query=query)
35
 
36
  # Display Planner breakdown
37
- print(Fore.GREEN + f"✅ Planner generated {len(result.sub_queries)} sub-queries in {result.planning_time_sec}s:")
38
- for idx, sq in enumerate(result.sub_queries, 1):
39
- print(Fore.CYAN + f" [{idx}] " + Fore.WHITE + f"{sq}")
40
- if result.plan_rationale:
41
- print(Fore.LIGHTBLACK_EX + f" Rationale: {result.plan_rationale}\n")
42
  else:
43
- print()
 
 
 
 
 
 
44
 
45
  print(Fore.BLUE + f"🔍 [Step 2/3] Search Agent executing multi-search with URL deduplication...")
46
  print(Fore.GREEN + f"✅ Retrieved {len(result.search_results)} unique sources across all sub-queries in {result.search_time_sec}s\n")
 
34
  result = pipeline.run(query=query)
35
 
36
  # Display Planner breakdown
37
+ if result.is_fallback:
38
+ print(Fore.YELLOW + Style.BRIGHT + f"⚠️ [WARNING] Planner fallback was triggered (single query search):\n Reason: {result.plan_rationale}\n")
 
 
 
39
  else:
40
+ print(Fore.GREEN + f"✅ Planner generated {len(result.sub_queries)} sub-queries in {result.planning_time_sec}s:")
41
+ for idx, sq in enumerate(result.sub_queries, 1):
42
+ print(Fore.CYAN + f" [{idx}] " + Fore.WHITE + f"{sq}")
43
+ if result.plan_rationale:
44
+ print(Fore.LIGHTBLACK_EX + f" Rationale: {result.plan_rationale}\n")
45
+ else:
46
+ print()
47
 
48
  print(Fore.BLUE + f"🔍 [Step 2/3] Search Agent executing multi-search with URL deduplication...")
49
  print(Fore.GREEN + f"✅ Retrieved {len(result.search_results)} unique sources across all sub-queries in {result.search_time_sec}s\n")
src/agents/planner_agent.py CHANGED
@@ -1,7 +1,6 @@
1
  import json
2
  import logging
3
  import re
4
- import time
5
  from typing import List, Dict, Any, Tuple, Optional
6
  from groq import Groq
7
  from pydantic import BaseModel, Field
@@ -15,27 +14,40 @@ class PlanOutput(BaseModel):
15
  """Structured plan output generated by the PlannerAgent."""
16
  sub_queries: List[str] = Field(
17
  ...,
18
- description="List of 2 to 4 distinct, targeted search queries to thoroughly investigate the topic."
19
  )
20
  rationale: str = Field(
21
  default="",
22
  description="Brief explanation of how these sub-queries cover the research problem."
23
  )
 
 
 
 
24
 
25
 
26
  class PlannerAgent:
27
  """Agent responsible for decomposing a complex research question into focused sub-queries."""
28
 
29
- def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
 
 
 
 
 
 
30
  self.api_key = api_key or settings.groq_api_key
31
  self.model = model or settings.groq_model
 
 
 
32
  if not self.api_key:
33
  raise ValueError("Groq API key is missing. Please set GROQ_API_KEY in .env.")
34
  self.client = Groq(api_key=self.api_key)
35
 
36
  def plan(self, query: str, max_sub_queries: Optional[int] = None) -> Tuple[PlanOutput, Dict[str, Any]]:
37
  """
38
- Decomposes user query into 2-4 focused sub-queries.
39
 
40
  Args:
41
  query: The user's original research question.
@@ -47,21 +59,24 @@ class PlannerAgent:
47
  limit = max_sub_queries or settings.max_sub_queries
48
  limit = max(1, min(limit, 4)) # Constrain between 1 and 4
49
 
 
 
50
  system_prompt = (
51
- "You are a master Research Strategist and Planner AI. Your goal is to analyze a complex "
52
- "research question and decompose it into 2 to {limit} distinct, highly specific search queries.\n\n"
53
  "GUIDELINES:\n"
54
  "1. Each sub-query must target a distinct dimension of the question (e.g., technical definitions, comparisons, real-world benchmarks, trade-offs).\n"
55
  "2. Ensure queries are formatted as effective search engine queries (concise, clear, and keyword-rich).\n"
56
  "3. Do NOT produce redundant or overlapping queries.\n"
57
- "4. Return strictly valid JSON adhering to this structure:\n"
 
58
  "{\n"
59
- ' "sub_queries": ["query 1", "query 2", ...],\n'
60
- ' "rationale": "Brief 1-2 sentence reason for choosing these sub-queries"\n'
61
  "}"
62
- ).replace("{limit}", str(limit))
63
 
64
- user_prompt = f"Decompose this research question into {limit} focused search queries:\n\"{query}\""
65
 
66
  logger.info(f"Invoking PlannerAgent with model '{self.model}' for query: '{query}' (limit={limit})")
67
 
@@ -72,12 +87,13 @@ class PlannerAgent:
72
  {"role": "user", "content": user_prompt}
73
  ],
74
  model=self.model,
75
- temperature=0.1, # Low temperature for deterministic planning
76
- max_tokens=500,
77
- response_format={"type": "json_object"}
78
  )
79
 
80
- raw_content = chat_completion.choices[0].message.content
 
 
81
  usage = {
82
  "prompt_tokens": chat_completion.usage.prompt_tokens if chat_completion.usage else 0,
83
  "completion_tokens": chat_completion.usage.completion_tokens if chat_completion.usage else 0,
@@ -90,28 +106,45 @@ class PlannerAgent:
90
  return plan_output, usage
91
 
92
  except Exception as e:
93
- logger.warning(f"PlannerAgent failed or raised exception: {e}. Falling back to original query.")
94
  fallback_plan = PlanOutput(
95
  sub_queries=[query],
96
- rationale=f"Fallback to single query due to planner error: {str(e)}"
 
97
  )
98
  return fallback_plan, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "model": self.model}
99
 
100
  def _parse_response(self, content: str, fallback_query: str, limit: int) -> PlanOutput:
101
- """Extracts and validates PlanOutput from JSON response string."""
102
  try:
103
- # Handle potential markdown code block wrappers
104
  clean_content = content.strip()
 
 
105
  if "```json" in clean_content:
106
  clean_content = clean_content.split("```json")[1].split("```")[0].strip()
107
  elif "```" in clean_content:
108
  clean_content = clean_content.split("```")[1].split("```")[0].strip()
109
 
 
 
 
 
 
 
110
  data = json.loads(clean_content)
111
- sub_queries = data.get("sub_queries", [])
 
 
 
 
 
 
 
 
 
112
 
113
  # Filter and sanitize sub-queries
114
- valid_queries = [q.strip() for q in sub_queries if isinstance(q, str) and q.strip()]
115
 
116
  if not valid_queries:
117
  raise ValueError("Parsed JSON contains empty sub_queries list.")
@@ -121,12 +154,14 @@ class PlannerAgent:
121
 
122
  return PlanOutput(
123
  sub_queries=valid_queries,
124
- rationale=data.get("rationale", "")
 
125
  )
126
 
127
  except Exception as err:
128
  logger.warning(f"Failed to parse JSON planner output: {err}. Content was: {content[:200]}")
129
  return PlanOutput(
130
  sub_queries=[fallback_query],
131
- rationale=f"Fallback used due to parsing exception ({err})"
 
132
  )
 
1
  import json
2
  import logging
3
  import re
 
4
  from typing import List, Dict, Any, Tuple, Optional
5
  from groq import Groq
6
  from pydantic import BaseModel, Field
 
14
  """Structured plan output generated by the PlannerAgent."""
15
  sub_queries: List[str] = Field(
16
  ...,
17
+ description="List of distinct, targeted search queries to investigate the topic."
18
  )
19
  rationale: str = Field(
20
  default="",
21
  description="Brief explanation of how these sub-queries cover the research problem."
22
  )
23
+ is_fallback: bool = Field(
24
+ default=False,
25
+ description="True if the plan is an automated fallback due to model or parsing error."
26
+ )
27
 
28
 
29
  class PlannerAgent:
30
  """Agent responsible for decomposing a complex research question into focused sub-queries."""
31
 
32
+ def __init__(
33
+ self,
34
+ api_key: Optional[str] = None,
35
+ model: Optional[str] = None,
36
+ temperature: Optional[float] = None,
37
+ max_tokens: Optional[int] = None
38
+ ):
39
  self.api_key = api_key or settings.groq_api_key
40
  self.model = model or settings.groq_model
41
+ self.temperature = temperature if temperature is not None else settings.planner_temperature
42
+ self.max_tokens = max_tokens if max_tokens is not None else settings.planner_max_tokens
43
+
44
  if not self.api_key:
45
  raise ValueError("Groq API key is missing. Please set GROQ_API_KEY in .env.")
46
  self.client = Groq(api_key=self.api_key)
47
 
48
  def plan(self, query: str, max_sub_queries: Optional[int] = None) -> Tuple[PlanOutput, Dict[str, Any]]:
49
  """
50
+ Decomposes user query into focused sub-queries.
51
 
52
  Args:
53
  query: The user's original research question.
 
59
  limit = max_sub_queries or settings.max_sub_queries
60
  limit = max(1, min(limit, 4)) # Constrain between 1 and 4
61
 
62
+ query_count_phrase = f"{limit} focused search query" if limit == 1 else f"2 to {limit} distinct, highly specific search queries"
63
+
64
  system_prompt = (
65
+ f"You are a master Research Strategist and Planner AI. Your goal is to analyze a complex "
66
+ f"research question and decompose it into {query_count_phrase}.\n\n"
67
  "GUIDELINES:\n"
68
  "1. Each sub-query must target a distinct dimension of the question (e.g., technical definitions, comparisons, real-world benchmarks, trade-offs).\n"
69
  "2. Ensure queries are formatted as effective search engine queries (concise, clear, and keyword-rich).\n"
70
  "3. Do NOT produce redundant or overlapping queries.\n"
71
+ "4. Return strictly valid JSON with keys 'sub_queries' (list of strings) and 'rationale' (string).\n"
72
+ "Example JSON:\n"
73
  "{\n"
74
+ ' "sub_queries": ["query 1", "query 2"],\n'
75
+ ' "rationale": "Explanation here"\n'
76
  "}"
77
+ )
78
 
79
+ user_prompt = f"Decompose this research question into search queries:\n\"{query}\""
80
 
81
  logger.info(f"Invoking PlannerAgent with model '{self.model}' for query: '{query}' (limit={limit})")
82
 
 
87
  {"role": "user", "content": user_prompt}
88
  ],
89
  model=self.model,
90
+ temperature=self.temperature,
91
+ max_tokens=self.max_tokens,
 
92
  )
93
 
94
+ msg = chat_completion.choices[0].message
95
+ raw_content = msg.content or getattr(msg, "reasoning", "") or ""
96
+
97
  usage = {
98
  "prompt_tokens": chat_completion.usage.prompt_tokens if chat_completion.usage else 0,
99
  "completion_tokens": chat_completion.usage.completion_tokens if chat_completion.usage else 0,
 
106
  return plan_output, usage
107
 
108
  except Exception as e:
109
+ logger.warning(f"⚠️ PlannerAgent failed or raised exception: {e}. Falling back to original query.")
110
  fallback_plan = PlanOutput(
111
  sub_queries=[query],
112
+ rationale=f"Fallback to single query due to planner error: {str(e)}",
113
+ is_fallback=True
114
  )
115
  return fallback_plan, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "model": self.model}
116
 
117
  def _parse_response(self, content: str, fallback_query: str, limit: int) -> PlanOutput:
118
+ """Extracts and validates PlanOutput from JSON response string, markdown blocks, or embedded json."""
119
  try:
 
120
  clean_content = content.strip()
121
+
122
+ # Extract content from markdown code fence if present
123
  if "```json" in clean_content:
124
  clean_content = clean_content.split("```json")[1].split("```")[0].strip()
125
  elif "```" in clean_content:
126
  clean_content = clean_content.split("```")[1].split("```")[0].strip()
127
 
128
+ # If not pure json, try finding outermost JSON object { ... } or array [ ... ]
129
+ if not (clean_content.startswith("{") or clean_content.startswith("[")):
130
+ match = re.search(r"(\{.*\}|\[.*\])", clean_content, re.DOTALL)
131
+ if match:
132
+ clean_content = match.group(1).strip()
133
+
134
  data = json.loads(clean_content)
135
+
136
+ # Handle case where JSON is a list of strings directly: ["q1", "q2"]
137
+ if isinstance(data, list):
138
+ sub_queries = data
139
+ rationale = "Direct sub-query list generated by planner."
140
+ elif isinstance(data, dict):
141
+ sub_queries = data.get("sub_queries") or data.get("queries") or []
142
+ rationale = data.get("rationale", "")
143
+ else:
144
+ raise ValueError("Parsed JSON root is neither an object nor a list.")
145
 
146
  # Filter and sanitize sub-queries
147
+ valid_queries = [str(q).strip() for q in sub_queries if str(q).strip()]
148
 
149
  if not valid_queries:
150
  raise ValueError("Parsed JSON contains empty sub_queries list.")
 
154
 
155
  return PlanOutput(
156
  sub_queries=valid_queries,
157
+ rationale=rationale,
158
+ is_fallback=False
159
  )
160
 
161
  except Exception as err:
162
  logger.warning(f"Failed to parse JSON planner output: {err}. Content was: {content[:200]}")
163
  return PlanOutput(
164
  sub_queries=[fallback_query],
165
+ rationale=f"Fallback used due to parsing exception ({err})",
166
+ is_fallback=True
167
  )
src/agents/search_agent.py CHANGED
@@ -70,8 +70,9 @@ class SearchAgent:
70
  limit_per_query = max_results_per_query or settings.max_results_per_subquery
71
  logger.info(f"Executing multi-search across {len(queries)} sub-queries (limit={limit_per_query} each)")
72
 
 
 
73
  all_results = []
74
- seen_urls = set()
75
 
76
  for idx, sub_query in enumerate(queries, 1):
77
  try:
@@ -83,15 +84,21 @@ class SearchAgent:
83
 
84
  for item in sub_results:
85
  url = item.get("url", "").strip()
86
- # Deduplicate by URL
87
- if url and url in seen_urls:
88
- logger.debug(f"Skipping duplicate URL already retrieved: {url}")
89
- continue
90
- if url:
91
- seen_urls.add(url)
92
-
93
  item["matched_sub_query"] = sub_query
94
- all_results.append(item)
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  except Exception as e:
97
  logger.warning(f"Sub-query search failed for '{sub_query}': {e}. Continuing with remaining queries.")
 
70
  limit_per_query = max_results_per_query or settings.max_results_per_subquery
71
  logger.info(f"Executing multi-search across {len(queries)} sub-queries (limit={limit_per_query} each)")
72
 
73
+ # Map url -> index in all_results to retain highest-scoring result
74
+ url_to_index = {}
75
  all_results = []
 
76
 
77
  for idx, sub_query in enumerate(queries, 1):
78
  try:
 
84
 
85
  for item in sub_results:
86
  url = item.get("url", "").strip()
87
+ score = item.get("score", 0.0)
 
 
 
 
 
 
88
  item["matched_sub_query"] = sub_query
89
+
90
+ if url and url in url_to_index:
91
+ existing_idx = url_to_index[url]
92
+ existing_score = all_results[existing_idx].get("score", 0.0)
93
+ if score > existing_score:
94
+ logger.debug(f"Updating duplicate URL '{url}' with higher score: {score} > {existing_score}")
95
+ all_results[existing_idx] = item
96
+ else:
97
+ logger.debug(f"Skipping duplicate URL '{url}' with lower/equal score: {score} <= {existing_score}")
98
+ else:
99
+ if url:
100
+ url_to_index[url] = len(all_results)
101
+ all_results.append(item)
102
 
103
  except Exception as e:
104
  logger.warning(f"Sub-query search failed for '{sub_query}': {e}. Continuing with remaining queries.")
src/config.py CHANGED
@@ -23,6 +23,8 @@ class Settings(BaseModel):
23
  max_results_per_subquery: int = Field(default_factory=lambda: int(os.getenv("MAX_RESULTS_PER_SUBQUERY", "3")))
24
  temperature: float = Field(default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2")))
25
  max_tokens: int = Field(default_factory=lambda: int(os.getenv("MAX_TOKENS", "1024")))
 
 
26
 
27
  def validate_keys(self) -> None:
28
  """Ensure required API keys are populated."""
 
23
  max_results_per_subquery: int = Field(default_factory=lambda: int(os.getenv("MAX_RESULTS_PER_SUBQUERY", "3")))
24
  temperature: float = Field(default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2")))
25
  max_tokens: int = Field(default_factory=lambda: int(os.getenv("MAX_TOKENS", "1024")))
26
+ planner_temperature: float = Field(default_factory=lambda: float(os.getenv("PLANNER_TEMPERATURE", "0.1")))
27
+ planner_max_tokens: int = Field(default_factory=lambda: int(os.getenv("PLANNER_MAX_TOKENS", "500")))
28
 
29
  def validate_keys(self) -> None:
30
  """Ensure required API keys are populated."""
src/logger.py CHANGED
@@ -6,11 +6,27 @@ BASE_DIR = Path(__file__).resolve().parent.parent
6
  LOGS_DIR = BASE_DIR / "logs"
7
 
8
 
9
- def setup_logging(level: int = logging.INFO, log_to_file: bool = True, log_to_console: bool = False) -> None:
10
- """Configures application-wide logging with file and optional console handlers."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  handlers = []
12
 
13
- # File Handler
14
  if log_to_file:
15
  LOGS_DIR.mkdir(parents=True, exist_ok=True)
16
  file_handler = logging.FileHandler(LOGS_DIR / "research_assistant.log", encoding="utf-8")
@@ -22,13 +38,10 @@ def setup_logging(level: int = logging.INFO, log_to_file: bool = True, log_to_co
22
  file_handler.setLevel(level)
23
  handlers.append(file_handler)
24
 
25
- # Console Handler (enabled if log_to_console=True or in DEBUG mode)
26
- if log_to_console or level <= logging.DEBUG:
27
  console_handler = logging.StreamHandler(sys.stdout)
28
- console_formatter = logging.Formatter(
29
- "\033[90m[DEBUG] [%(name)s] %(message)s\033[0m"
30
- )
31
- console_handler.setFormatter(console_formatter)
32
  console_handler.setLevel(level)
33
  handlers.append(console_handler)
34
 
 
6
  LOGS_DIR = BASE_DIR / "logs"
7
 
8
 
9
+ class ColoredConsoleFormatter(logging.Formatter):
10
+ """Format console output with subtle colors for debug and warnings."""
11
+
12
+ COLORS = {
13
+ logging.DEBUG: "\033[90m[DEBUG]\033[0m",
14
+ logging.INFO: "\033[36m[INFO]\033[0m",
15
+ logging.WARNING: "\033[33m[WARNING]\033[0m",
16
+ logging.ERROR: "\033[31m[ERROR]\033[0m",
17
+ logging.CRITICAL: "\033[41m[CRITICAL]\033[0m"
18
+ }
19
+
20
+ def format(self, record: logging.LogRecord) -> str:
21
+ level_tag = self.COLORS.get(record.levelno, f"[{record.levelname}]")
22
+ return f"{level_tag} [{record.name}] {record.getMessage()}"
23
+
24
+
25
+ def setup_logging(level: int = logging.INFO, log_to_file: bool = True, log_to_console: bool = True) -> None:
26
+ """Configures application-wide logging with file and live console handlers."""
27
  handlers = []
28
 
29
+ # 1. File Handler (captures all events with timestamp and lineno)
30
  if log_to_file:
31
  LOGS_DIR.mkdir(parents=True, exist_ok=True)
32
  file_handler = logging.FileHandler(LOGS_DIR / "research_assistant.log", encoding="utf-8")
 
38
  file_handler.setLevel(level)
39
  handlers.append(file_handler)
40
 
41
+ # 2. Live Console Stream Handler
42
+ if log_to_console:
43
  console_handler = logging.StreamHandler(sys.stdout)
44
+ console_handler.setFormatter(ColoredConsoleFormatter())
 
 
 
45
  console_handler.setLevel(level)
46
  handlers.append(console_handler)
47
 
src/pipeline.py CHANGED
@@ -22,6 +22,7 @@ class ResearchResult(BaseModel):
22
  search_time_sec: float = 0.0
23
  synthesis_time_sec: float = 0.0
24
  total_time_sec: float = 0.0
 
25
  usage: Dict[str, Any] = Field(default_factory=dict)
26
 
27
 
@@ -108,6 +109,7 @@ class ResearchPipeline:
108
  search_time_sec=round(search_time, 2),
109
  synthesis_time_sec=round(synthesis_time, 2),
110
  total_time_sec=round(total_time, 2),
 
111
  usage=total_usage
112
  )
113
 
 
22
  search_time_sec: float = 0.0
23
  synthesis_time_sec: float = 0.0
24
  total_time_sec: float = 0.0
25
+ is_fallback: bool = False
26
  usage: Dict[str, Any] = Field(default_factory=dict)
27
 
28
 
 
109
  search_time_sec=round(search_time, 2),
110
  synthesis_time_sec=round(synthesis_time, 2),
111
  total_time_sec=round(total_time, 2),
112
+ is_fallback=plan_output.is_fallback,
113
  usage=total_usage
114
  )
115
 
tests/test_phase2.py CHANGED
@@ -47,11 +47,14 @@ class TestPhase2PlannerAndMultiSearch(unittest.TestCase):
47
 
48
  results = search_agent.search_multi(["query 1", "query 2"], max_results_per_query=2)
49
 
50
- # Should deduplicate https://example.com/shared and return 3 total unique items
51
  self.assertEqual(len(results), 3)
52
  unique_urls = [r["url"] for r in results]
53
  self.assertEqual(unique_urls, ["https://example.com/shared", "https://example.com/doc-b", "https://example.com/doc-c"])
54
- self.assertEqual(results[0]["matched_sub_query"], "query 1")
 
 
 
55
  self.assertEqual(results[2]["matched_sub_query"], "query 2")
56
 
57
  @patch("src.pipeline.settings")
 
47
 
48
  results = search_agent.search_multi(["query 1", "query 2"], max_results_per_query=2)
49
 
50
+ # Should deduplicate https://example.com/shared and return 3 total unique items, retaining highest-scored version
51
  self.assertEqual(len(results), 3)
52
  unique_urls = [r["url"] for r in results]
53
  self.assertEqual(unique_urls, ["https://example.com/shared", "https://example.com/doc-b", "https://example.com/doc-c"])
54
+ # Verify higher-scored Doc A (0.95 from query 2) replaced the lower-scored one (0.9 from query 1)
55
+ self.assertEqual(results[0]["score"], 0.95)
56
+ self.assertEqual(results[0]["title"], "Doc A (Duplicate)")
57
+ self.assertEqual(results[0]["matched_sub_query"], "query 2")
58
  self.assertEqual(results[2]["matched_sub_query"], "query 2")
59
 
60
  @patch("src.pipeline.settings")