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 +7 -1
- README.md +28 -14
- main.py +9 -6
- src/agents/planner_agent.py +58 -23
- src/agents/search_agent.py +16 -9
- src/config.py +2 -0
- src/logger.py +22 -9
- src/pipeline.py +2 -0
- tests/test_phase2.py +5 -2
.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
|
| 73 |
|
| 74 |
-
| Metric |
|
| 75 |
-
| :--- | :--- | :--- | :--- |
|
| 76 |
-
| **
|
| 77 |
-
| **
|
| 78 |
-
| **
|
| 79 |
-
| **
|
|
|
|
| 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 &
|
| 96 |
-
│ ├── logger.py #
|
| 97 |
-
│ ├── pipeline.py #
|
| 98 |
│ └── agents/
|
| 99 |
│ ├── __init__.py
|
| 100 |
-
│ ├──
|
|
|
|
| 101 |
│ └── writer_agent.py # Groq Synthesis & Grounding agent
|
| 102 |
└── tests/
|
| 103 |
-
|
|
|
|
| 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 |
-
|
| 38 |
-
|
| 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
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
|
|
|
| 58 |
"{\n"
|
| 59 |
-
' "sub_queries": ["query 1", "query 2"
|
| 60 |
-
' "rationale": "
|
| 61 |
"}"
|
| 62 |
-
)
|
| 63 |
|
| 64 |
-
user_prompt = f"Decompose this research question into
|
| 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=
|
| 76 |
-
max_tokens=
|
| 77 |
-
response_format={"type": "json_object"}
|
| 78 |
)
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# Filter and sanitize sub-queries
|
| 114 |
-
valid_queries = [q.strip() for q in sub_queries if
|
| 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=
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 10 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 26 |
-
if log_to_console
|
| 27 |
console_handler = logging.StreamHandler(sys.stdout)
|
| 28 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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")
|