Spaces:
Runtime error
Runtime error
Commit ·
da4758c
0
Parent(s):
Initial clean deployment
Browse files- .gitignore +8 -0
- Researchmind-AI/.gitignore +86 -0
- Researchmind-AI/LICENSE +21 -0
- Researchmind-AI/README.md +198 -0
- Researchmind-AI/app.py +292 -0
- Researchmind-AI/evaluate.py +156 -0
- Researchmind-AI/evaluation/topics.txt +10 -0
- Researchmind-AI/pytest.ini +3 -0
- Researchmind-AI/requirements-dev.txt +2 -0
- Researchmind-AI/requirements.txt +10 -0
- Researchmind-AI/src/agents.py +232 -0
- Researchmind-AI/src/chunking.py +22 -0
- Researchmind-AI/src/config.py +33 -0
- Researchmind-AI/src/documents.py +57 -0
- Researchmind-AI/src/embeddings.py +35 -0
- Researchmind-AI/src/evidence.py +99 -0
- Researchmind-AI/src/llm.py +102 -0
- Researchmind-AI/src/pipeline.py +260 -0
- Researchmind-AI/src/prompts.py +112 -0
- Researchmind-AI/src/search.py +54 -0
- Researchmind-AI/tests/__init__.py +0 -0
- Researchmind-AI/tests/conftest.py +93 -0
- Researchmind-AI/tests/test_app.py +61 -0
- Researchmind-AI/tests/test_pipeline.py +230 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
*.png
|
| 3 |
+
*.jpg
|
| 4 |
+
*.jpeg
|
| 5 |
+
|
| 6 |
+
*.png
|
| 7 |
+
*.jpg
|
| 8 |
+
*.jpeg
|
Researchmind-AI/.gitignore
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==================================================
|
| 2 |
+
# Python
|
| 3 |
+
# ==================================================
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
*.pyo
|
| 7 |
+
*.pyd
|
| 8 |
+
*.so
|
| 9 |
+
|
| 10 |
+
# ==================================================
|
| 11 |
+
# Virtual Environments
|
| 12 |
+
# ==================================================
|
| 13 |
+
venv/
|
| 14 |
+
.venv/
|
| 15 |
+
env/
|
| 16 |
+
ENV/
|
| 17 |
+
|
| 18 |
+
# ==================================================
|
| 19 |
+
# Environment Variables (API Keys)
|
| 20 |
+
# ==================================================
|
| 21 |
+
.env
|
| 22 |
+
.env.*
|
| 23 |
+
!.env.example
|
| 24 |
+
|
| 25 |
+
# ==================================================
|
| 26 |
+
# Gradio
|
| 27 |
+
# ==================================================
|
| 28 |
+
.gradio/
|
| 29 |
+
|
| 30 |
+
# ==================================================
|
| 31 |
+
# Snapshots
|
| 32 |
+
# ==================================================
|
| 33 |
+
.snapshots/
|
| 34 |
+
|
| 35 |
+
# ==================================================
|
| 36 |
+
# Generated Reports
|
| 37 |
+
# ==================================================
|
| 38 |
+
ResearchMind_Report.pdf
|
| 39 |
+
research_report.pdf
|
| 40 |
+
research_report.md
|
| 41 |
+
|
| 42 |
+
# ==================================================
|
| 43 |
+
# Output Files
|
| 44 |
+
# ==================================================
|
| 45 |
+
output/
|
| 46 |
+
|
| 47 |
+
# ==================================================
|
| 48 |
+
# Logs
|
| 49 |
+
# ==================================================
|
| 50 |
+
*.log
|
| 51 |
+
|
| 52 |
+
# ==================================================
|
| 53 |
+
# IDE
|
| 54 |
+
# ==================================================
|
| 55 |
+
.vscode/
|
| 56 |
+
.idea/
|
| 57 |
+
|
| 58 |
+
# ==================================================
|
| 59 |
+
# Operating System
|
| 60 |
+
# ==================================================
|
| 61 |
+
.DS_Store
|
| 62 |
+
Thumbs.db
|
| 63 |
+
|
| 64 |
+
# ==================================================
|
| 65 |
+
# Python Cache
|
| 66 |
+
# ==================================================
|
| 67 |
+
.pytest_cache/
|
| 68 |
+
.mypy_cache/
|
| 69 |
+
|
| 70 |
+
# ==================================================
|
| 71 |
+
# Jupyter
|
| 72 |
+
# ==================================================
|
| 73 |
+
.ipynb_checkpoints/
|
| 74 |
+
|
| 75 |
+
# ==================================================
|
| 76 |
+
# Build Files
|
| 77 |
+
# ==================================================
|
| 78 |
+
build/
|
| 79 |
+
dist/
|
| 80 |
+
*.egg-info/
|
| 81 |
+
|
| 82 |
+
# ==================================================
|
| 83 |
+
# Temporary Files
|
| 84 |
+
# ==================================================
|
| 85 |
+
*.tmp
|
| 86 |
+
*.temp
|
Researchmind-AI/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Muhammad Azfar
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Researchmind-AI/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🔬 ResearchMind AI
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+

|
| 7 |
+

|
| 8 |
+

|
| 9 |
+

|
| 10 |
+

|
| 11 |
+

|
| 12 |
+

|
| 13 |
+
|
| 14 |
+
### A multi-agent research assistant that plans, researches, writes, and **fact-checks its own reports**
|
| 15 |
+
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 📖 Overview
|
| 21 |
+
|
| 22 |
+
Most AI research tools return fluent text you have to trust blindly. ResearchMind AI is built around a different idea: **every claim in the report is cited, and a second agent checks each claim against the exact passages it cites** before you see the result.
|
| 23 |
+
|
| 24 |
+
Give it a topic and a team of cooperating agents does the work:
|
| 25 |
+
|
| 26 |
+
| Agent | Job |
|
| 27 |
+
|---|---|
|
| 28 |
+
| 🧭 **Planner** | Breaks the topic into focused research sub-questions |
|
| 29 |
+
| 🔍 **Researcher** | Searches the web for every sub-question in parallel (Tavily) |
|
| 30 |
+
| 🧩 **Retriever** | Chunks and embeds all sources, then ranks the best passages with FAISS cosine search |
|
| 31 |
+
| ✍️ **Writer** | Drafts a report that may only use the retrieved sources, citing them as `[1]`, `[2]` |
|
| 32 |
+
| 🔎 **Checker** | Verifies each cited claim against the passages it cites: *supported / partial / unsupported* |
|
| 33 |
+
| 🛠️ **Reviser** | Rewrites or removes flagged claims, then the Checker re-verifies (the feedback loop) |
|
| 34 |
+
| 🌍 **Translator** | Optionally renders the *verified* English report in Urdu |
|
| 35 |
+
|
| 36 |
+
The UI shows this activity live: the plan, sources found, passages retrieved, and the fact-check verdicts.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## ✨ Features
|
| 41 |
+
|
| 42 |
+
- **Agentic pipeline** with a self-correcting verify → revise loop
|
| 43 |
+
- **Real RAG**: per-request in-memory FAISS index (normalized embeddings, cosine similarity), per-source caps so one page cannot dominate, relevance floor to drop weak passages
|
| 44 |
+
- **Inline citations** `[n]` with a sources list built by code (URLs are never invented by the model); invented citation numbers are stripped automatically
|
| 45 |
+
- **Claim-level verification** with an honest score shown on every report
|
| 46 |
+
- **Bring your own documents**: upload PDF / TXT / MD, mix them with web results, or run on your documents only
|
| 47 |
+
- **Research depth** presets: Quick, Standard, Deep
|
| 48 |
+
- **Report styles**: Standard, Summary, Academic
|
| 49 |
+
- **English and Urdu** output (Urdu is translated *after* verification, so the checking happens on the original English text)
|
| 50 |
+
- **Export** to Markdown (all languages) and PDF (English) with clickable source links
|
| 51 |
+
- **Model fallback and rate-limit retry** across several Groq models
|
| 52 |
+
- **Offline test suite** (26 tests) and a **batch evaluation script**
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
## 🏗️ Architecture
|
| 57 |
+
|
| 58 |
+
```text
|
| 59 |
+
Topic + options (style, depth, language, documents)
|
| 60 |
+
│
|
| 61 |
+
▼
|
| 62 |
+
┌────────────────────────┐
|
| 63 |
+
│ Planner (fast LLM) │──► 2–5 sub-questions
|
| 64 |
+
└───────────┬────────────┘
|
| 65 |
+
▼
|
| 66 |
+
┌─────────────────────────────────────┐
|
| 67 |
+
│ Researcher: parallel Tavily search │ + uploaded PDF/TXT/MD
|
| 68 |
+
└───────────────────┬─────────────────┘
|
| 69 |
+
▼
|
| 70 |
+
┌─────────────────────────────────────┐
|
| 71 |
+
│ Retriever: chunk → embed (MiniLM) │
|
| 72 |
+
│ → FAISS cosine index → top passages │
|
| 73 |
+
│ per sub-question │
|
| 74 |
+
└───────────────────┬─────────────────┘
|
| 75 |
+
▼
|
| 76 |
+
┌────────────────────────┐
|
| 77 |
+
│ Writer (70B, streaming)│──► cited draft
|
| 78 |
+
└───────────┬────────────┘
|
| 79 |
+
▼
|
| 80 |
+
┌─────────────────────────────────────���───────┐
|
| 81 |
+
│ Checker: each claim vs. the passages it cites│
|
| 82 |
+
└───────────┬─────────────────────────────────┘
|
| 83 |
+
│ flagged claims?
|
| 84 |
+
yes ┌───┴───┐ no
|
| 85 |
+
▼ │
|
| 86 |
+
┌──────────────┐│
|
| 87 |
+
│ Reviser ││ (re-check; keep the revision only if it scores better)
|
| 88 |
+
└──────┬───────┘│
|
| 89 |
+
└────────┤
|
| 90 |
+
▼
|
| 91 |
+
Translator (optional) → Report + Sources + Verification
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## 🧰 Tech Stack
|
| 97 |
+
|
| 98 |
+
Python · Gradio · Groq (Llama 3.3 70B for writing, Llama 3.1 8B for planning and checking, with fallbacks) · Tavily · Sentence-Transformers (`all-MiniLM-L6-v2`) · FAISS · LangChain text splitters · pypdf · ReportLab
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## 🚀 Getting Started
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
git clone https://github.com/muhammadazfar-ai/ResearchMind-AI.git
|
| 106 |
+
cd ResearchMind-AI
|
| 107 |
+
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 108 |
+
pip install -r requirements.txt
|
| 109 |
+
cp .env.example .env # then add your keys
|
| 110 |
+
python app.py
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
You need a free [Groq](https://console.groq.com) key and a [Tavily](https://tavily.com) key. Never commit `.env`.
|
| 114 |
+
|
| 115 |
+
### Run on Hugging Face Spaces
|
| 116 |
+
1. Create a new **Gradio** Space and push this repository.
|
| 117 |
+
2. In *Settings → Variables and secrets*, add `GROQ_API_KEY` and `TAVILY_API_KEY` as **secrets**.
|
| 118 |
+
3. The Space installs `requirements.txt` and runs `app.py` automatically.
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 📊 Evaluation
|
| 123 |
+
|
| 124 |
+
Run the pipeline over a topic list and measure how well-grounded the reports are:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python evaluate.py --limit 5 # first 5 topics in evaluation/topics.txt
|
| 128 |
+
python evaluate.py --depth Quick --pause 15
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
It writes `evaluation/results.json` and `evaluation/RESULTS.md` with, per topic and on average:
|
| 132 |
+
- **Claim support rate**: share of cited claims fully supported by their sources, for the first draft and after the Checker/Reviser loop
|
| 133 |
+
- **Citation coverage**: share of factual sentences that carry a citation
|
| 134 |
+
- Sources per report, claims checked, and time per report
|
| 135 |
+
|
| 136 |
+
> Paste your own `RESULTS.md` summary here after running it, so the numbers in this README are yours.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## 🧪 Tests
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
pip install -r requirements-dev.txt
|
| 144 |
+
python -m pytest
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
The suite runs fully offline with scripted fake search, embedding and LLM components. It covers retrieval ranking, citation cleaning, claim extraction, the full verify → revise loop (including a planted false claim that must be caught and removed), document loading, Urdu translation flow, error paths, and the UI handler.
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## ⚙️ Configuration
|
| 152 |
+
|
| 153 |
+
All tuning lives in [`src/config.py`](src/config.py): chunk size, depth presets, how many passages go to the Writer, relevance thresholds, fact-check batch size. Model order per role is in [`src/llm.py`](src/llm.py) and can be overridden with `GROQ_WRITER_MODEL` / `GROQ_FAST_MODEL`.
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## ⚠️ Limitations
|
| 158 |
+
|
| 159 |
+
- **The fact-check measures grounding, not absolute truth.** An LLM judges whether a claim matches the passages it cites. If a source is wrong, the report can still be "supported". Verify critical facts yourself.
|
| 160 |
+
- **Free-tier rate limits.** Deep research makes many API calls; the app retries and falls back to other models, but it can still be slow or fail under tight limits.
|
| 161 |
+
- **Urdu PDF export is not supported** (standard PDF fonts cannot shape Urdu). Urdu reports download as Markdown.
|
| 162 |
+
- **Scanned PDFs** have no text layer and need OCR before upload.
|
| 163 |
+
- The Urdu translation is machine-generated and was not reviewed by a native speaker.
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## 📂 Project Structure
|
| 168 |
+
|
| 169 |
+
```text
|
| 170 |
+
ResearchMind-AI/
|
| 171 |
+
├── app.py # Gradio UI: controls, live agent log, exports
|
| 172 |
+
├── evaluate.py # Batch evaluation (support rate, coverage, timing)
|
| 173 |
+
├── requirements.txt
|
| 174 |
+
├── requirements-dev.txt
|
| 175 |
+
├── pytest.ini
|
| 176 |
+
├── .env.example
|
| 177 |
+
├── evaluation/
|
| 178 |
+
│ └── topics.txt # Topics used by evaluate.py
|
| 179 |
+
├── src/
|
| 180 |
+
│ ├── agents.py # Planner, Researcher, Writer, Checker, Reviser, Translator
|
| 181 |
+
│ ├── pipeline.py # Orchestration + live activity trace
|
| 182 |
+
│ ├── evidence.py # Chunking, FAISS index, passage selection per question
|
| 183 |
+
│ ├── embeddings.py # Sentence-Transformers + cosine index
|
| 184 |
+
│ ├── search.py # Tavily web search
|
| 185 |
+
│ ├── documents.py # PDF / TXT / MD loading
|
| 186 |
+
│ ├── llm.py # Groq client, model fallback, streaming
|
| 187 |
+
│ ├── prompts.py # All agent prompts
|
| 188 |
+
│ ├── chunking.py # Recursive text splitter
|
| 189 |
+
│ └── config.py # Tunable settings
|
| 190 |
+
├── tests/ # Offline test suite
|
| 191 |
+
└── screenshot/
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## 📄 License
|
| 197 |
+
|
| 198 |
+
MIT. See [LICENSE](LICENSE).
|
Researchmind-AI/app.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import html
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import tempfile
|
| 5 |
+
import threading
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
from dotenv import find_dotenv, load_dotenv
|
| 9 |
+
|
| 10 |
+
# Search up the directory tree to find .env and load environment variables
|
| 11 |
+
load_dotenv(find_dotenv(), override=True)
|
| 12 |
+
|
| 13 |
+
# Startup verification logs for keys
|
| 14 |
+
if not os.getenv("GROQ_API_KEY"):
|
| 15 |
+
print("[WARNING] GROQ_API_KEY was not found in .env or environment variables.")
|
| 16 |
+
if not os.getenv("TAVILY_API_KEY"):
|
| 17 |
+
print("[WARNING] TAVILY_API_KEY was not found in .env or environment variables.")
|
| 18 |
+
|
| 19 |
+
import gradio as gr
|
| 20 |
+
from reportlab.lib.pagesizes import LETTER
|
| 21 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 22 |
+
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
| 23 |
+
|
| 24 |
+
from src.config import DEFAULT_DEPTH, DEPTH_PRESETS, LANGUAGES, MAX_UPLOAD_FILES, MODES
|
| 25 |
+
from src.embeddings import get_embedding_model
|
| 26 |
+
from src.pipeline import run_research
|
| 27 |
+
|
| 28 |
+
REPORTS_DIR = os.path.join(tempfile.gettempdir(), "researchmind_reports")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ======================================================
|
| 32 |
+
# EXPORT (Markdown + PDF)
|
| 33 |
+
# ======================================================
|
| 34 |
+
def _pdf_safe(text: str) -> str:
|
| 35 |
+
"""Standard PDF fonts only cover Windows-1252, so drop emoji and other unsupported symbols."""
|
| 36 |
+
return text.encode("cp1252", "ignore").decode("cp1252")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_markdown(text: str):
|
| 40 |
+
"""Saves the report as a .md file (works for every language)."""
|
| 41 |
+
if not text or len(text.strip()) < 10:
|
| 42 |
+
return None
|
| 43 |
+
os.makedirs(REPORTS_DIR, exist_ok=True)
|
| 44 |
+
path = os.path.join(REPORTS_DIR, f"ResearchMind_Report_{uuid.uuid4().hex[:8]}.md")
|
| 45 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 46 |
+
f.write(text)
|
| 47 |
+
return path
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def create_pdf(text: str):
|
| 51 |
+
"""Builds a PDF from the markdown report. English/Latin text only (see README for Urdu)."""
|
| 52 |
+
if not text or len(text.strip()) < 10:
|
| 53 |
+
return None
|
| 54 |
+
try:
|
| 55 |
+
os.makedirs(REPORTS_DIR, exist_ok=True)
|
| 56 |
+
path = os.path.join(REPORTS_DIR, f"ResearchMind_Report_{uuid.uuid4().hex[:8]}.pdf")
|
| 57 |
+
styles = getSampleStyleSheet()
|
| 58 |
+
story = []
|
| 59 |
+
for raw in text.split("\n"):
|
| 60 |
+
line = _pdf_safe(raw).strip()
|
| 61 |
+
if not line:
|
| 62 |
+
story.append(Spacer(1, 8))
|
| 63 |
+
continue
|
| 64 |
+
if set(line) <= {"-", " "}: # horizontal rule
|
| 65 |
+
story.append(Spacer(1, 6))
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
style, bullet = styles["BodyText"], None
|
| 69 |
+
heading = re.match(r"^(#+)\s*(.*)", line)
|
| 70 |
+
if heading:
|
| 71 |
+
style = styles["Heading1"] if len(heading.group(1)) == 1 else styles["Heading2"]
|
| 72 |
+
line = heading.group(2)
|
| 73 |
+
elif line.startswith(">"):
|
| 74 |
+
style, line = styles["Italic"], line.lstrip("> ").strip()
|
| 75 |
+
elif re.match(r"^[-*]\s+", line):
|
| 76 |
+
bullet, line = "\u2022", re.sub(r"^[-*]\s+", "", line)
|
| 77 |
+
|
| 78 |
+
line = html.escape(line)
|
| 79 |
+
line = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", line)
|
| 80 |
+
line = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'<a href="\2" color="blue">\1</a>', line)
|
| 81 |
+
story.append(Paragraph(line, style, bulletText=bullet))
|
| 82 |
+
|
| 83 |
+
SimpleDocTemplate(path, pagesize=LETTER).build(story)
|
| 84 |
+
return path
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"PDF Generation Error: {e}")
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def export_files(report: str, language: str) -> list:
|
| 91 |
+
files = [create_markdown(report)]
|
| 92 |
+
if language == "English":
|
| 93 |
+
files.append(create_pdf(report))
|
| 94 |
+
return [f for f in files if f]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ======================================================
|
| 98 |
+
# CORE HANDLER
|
| 99 |
+
# ======================================================
|
| 100 |
+
def run_agent(topic, mode, language, depth, use_web, doc_files):
|
| 101 |
+
"""Streams the agent pipeline into the UI: live activity log, report, then downloadable files."""
|
| 102 |
+
if not topic or len(topic.strip()) < 2:
|
| 103 |
+
yield (
|
| 104 |
+
gr.update(visible=True),
|
| 105 |
+
gr.update(value="", visible=False),
|
| 106 |
+
gr.update(value="", visible=False),
|
| 107 |
+
gr.update(value=None, visible=False),
|
| 108 |
+
)
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
rtl = language == "Urdu"
|
| 112 |
+
try:
|
| 113 |
+
for event in run_research(
|
| 114 |
+
topic.strip(), mode, language, depth, use_web, [getattr(f, "name", f) for f in (doc_files or [])]
|
| 115 |
+
):
|
| 116 |
+
report_update = gr.update() if event["report"] is None else gr.update(value=event["report"], visible=True, rtl=rtl)
|
| 117 |
+
files_update = gr.update(value=None, visible=False)
|
| 118 |
+
if event["done"]:
|
| 119 |
+
files = export_files(event["result"]["report"], language)
|
| 120 |
+
files_update = gr.update(value=files, visible=bool(files))
|
| 121 |
+
yield gr.update(visible=False), gr.update(value=event["trace"], visible=True), report_update, files_update
|
| 122 |
+
if event["error"]:
|
| 123 |
+
return
|
| 124 |
+
except Exception as e:
|
| 125 |
+
yield (
|
| 126 |
+
gr.update(visible=False),
|
| 127 |
+
gr.update(),
|
| 128 |
+
gr.update(value=f"### ❌ An Error Occurred:\n`{str(e)}`", visible=True),
|
| 129 |
+
gr.update(value=None, visible=False),
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def reset_ui():
|
| 134 |
+
"""Resets the UI state for starting a new research session."""
|
| 135 |
+
return (
|
| 136 |
+
"", # clear search input
|
| 137 |
+
gr.update(visible=True), # show welcome text
|
| 138 |
+
gr.update(value="", visible=False), # clear activity log
|
| 139 |
+
gr.update(value="", visible=False), # clear report
|
| 140 |
+
gr.update(value=None, visible=False), # clear downloads
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ======================================================
|
| 145 |
+
# STYLES
|
| 146 |
+
# ======================================================
|
| 147 |
+
custom_css = """
|
| 148 |
+
body, .gradio-container {
|
| 149 |
+
background-color: #0b0e14 !important;
|
| 150 |
+
color: #adbac7 !important;
|
| 151 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
|
| 152 |
+
}
|
| 153 |
+
footer { display: none !important; }
|
| 154 |
+
|
| 155 |
+
.sidebar-gpt {
|
| 156 |
+
background-color: #0d1117 !important;
|
| 157 |
+
border-right: 1px solid #2d333b !important;
|
| 158 |
+
padding: 16px !important;
|
| 159 |
+
}
|
| 160 |
+
.sidebar-btn {
|
| 161 |
+
background: transparent !important;
|
| 162 |
+
border: 1px solid #2d333b !important;
|
| 163 |
+
color: #adbac7 !important;
|
| 164 |
+
text-align: left !important;
|
| 165 |
+
justify-content: flex-start !important;
|
| 166 |
+
padding: 10px 14px !important;
|
| 167 |
+
border-radius: 10px !important;
|
| 168 |
+
cursor: pointer !important;
|
| 169 |
+
font-size: 14px !important;
|
| 170 |
+
margin-bottom: 8px !important;
|
| 171 |
+
width: 100% !important;
|
| 172 |
+
}
|
| 173 |
+
.sidebar-btn:hover {
|
| 174 |
+
background: #1c2128 !important;
|
| 175 |
+
color: #ffffff !important;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.chat-container {
|
| 179 |
+
max-width: 850px !important;
|
| 180 |
+
margin: 0 auto !important;
|
| 181 |
+
padding-bottom: 40px !important;
|
| 182 |
+
}
|
| 183 |
+
.welcome-text {
|
| 184 |
+
font-size: 32px;
|
| 185 |
+
font-weight: 700;
|
| 186 |
+
text-align: center;
|
| 187 |
+
margin-top: 40px;
|
| 188 |
+
color: #ffffff;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.report-gpt {
|
| 192 |
+
color: #adbac7 !important;
|
| 193 |
+
font-size: 16px !important;
|
| 194 |
+
line-height: 1.7 !important;
|
| 195 |
+
}
|
| 196 |
+
.report-gpt h1, .report-gpt h2, .report-gpt h3 {
|
| 197 |
+
color: #ffffff !important;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.input-bar-gpt {
|
| 201 |
+
background: #1c2128 !important;
|
| 202 |
+
border: 1px solid #2d333b !important;
|
| 203 |
+
border-radius: 16px !important;
|
| 204 |
+
padding: 8px 16px !important;
|
| 205 |
+
align-items: center !important;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.trace-box {
|
| 209 |
+
background: #0d1117 !important;
|
| 210 |
+
border: 1px solid #2d333b !important;
|
| 211 |
+
border-radius: 12px !important;
|
| 212 |
+
padding: 12px 16px !important;
|
| 213 |
+
font-size: 14px !important;
|
| 214 |
+
margin-bottom: 12px !important;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.send-btn {
|
| 218 |
+
background: #10a37f !important;
|
| 219 |
+
color: white !important;
|
| 220 |
+
border-radius: 50% !important;
|
| 221 |
+
min-width: 40px !important;
|
| 222 |
+
height: 40px !important;
|
| 223 |
+
border: none !important;
|
| 224 |
+
}
|
| 225 |
+
"""
|
| 226 |
+
|
| 227 |
+
# ======================================================
|
| 228 |
+
# UI STRUCTURE
|
| 229 |
+
# ======================================================
|
| 230 |
+
with gr.Blocks(title="ResearchMind AI") as app:
|
| 231 |
+
with gr.Row():
|
| 232 |
+
# LEFT SIDEBAR
|
| 233 |
+
with gr.Column(scale=1, elem_classes="sidebar-gpt", min_width=240):
|
| 234 |
+
gr.HTML("""<div style="font-size:20px; font-weight:800; margin-bottom:20px; color:#ffffff;">ResearchMind</div>""")
|
| 235 |
+
new_research_btn = gr.Button("➕ New Research", elem_classes="sidebar-btn")
|
| 236 |
+
gr.Markdown(
|
| 237 |
+
"**How it works**\n\n"
|
| 238 |
+
"1. 🧭 **Planner** splits your topic\n"
|
| 239 |
+
"2. 🔍 **Researcher** searches the web\n"
|
| 240 |
+
"3. 🧩 **Retriever** indexes & ranks passages\n"
|
| 241 |
+
"4. ✍️ **Writer** drafts with citations\n"
|
| 242 |
+
"5. 🔎 **Checker** verifies every claim\n"
|
| 243 |
+
"6. 🛠️ **Reviser** fixes weak claims\n"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# MAIN AREA
|
| 247 |
+
with gr.Column(scale=4):
|
| 248 |
+
with gr.Row():
|
| 249 |
+
mode = gr.Dropdown(choices=MODES, value="Standard", label="Style", scale=2)
|
| 250 |
+
depth = gr.Dropdown(choices=list(DEPTH_PRESETS), value=DEFAULT_DEPTH, label="Depth", scale=2)
|
| 251 |
+
language = gr.Dropdown(choices=LANGUAGES, value="English", label="Language", scale=2)
|
| 252 |
+
|
| 253 |
+
with gr.Column(elem_classes="chat-container"):
|
| 254 |
+
welcome = gr.HTML('<div class="welcome-text">What would you like to research?</div>')
|
| 255 |
+
trace_out = gr.Markdown(visible=False, elem_classes="trace-box")
|
| 256 |
+
report_out = gr.Markdown(visible=False, elem_classes="report-gpt")
|
| 257 |
+
files_out = gr.File(label="Download report", file_count="multiple", visible=False)
|
| 258 |
+
|
| 259 |
+
with gr.Row(elem_classes="input-bar-gpt"):
|
| 260 |
+
query = gr.Textbox(placeholder="Ask anything...", lines=1, container=False, scale=10)
|
| 261 |
+
submit_btn = gr.Button("↑", variant="primary", elem_classes="send-btn", scale=1)
|
| 262 |
+
|
| 263 |
+
with gr.Row():
|
| 264 |
+
use_web = gr.Checkbox(label="🌐 Include web search", value=True)
|
| 265 |
+
with gr.Accordion(f"📎 Add your own documents (PDF, TXT, MD — up to {MAX_UPLOAD_FILES})", open=False):
|
| 266 |
+
doc_files = gr.File(file_count="multiple", file_types=[".pdf", ".txt", ".md"], label="Your documents")
|
| 267 |
+
|
| 268 |
+
# Event handlers
|
| 269 |
+
run_inputs = [query, mode, language, depth, use_web, doc_files]
|
| 270 |
+
run_outputs = [welcome, trace_out, report_out, files_out]
|
| 271 |
+
|
| 272 |
+
submit_event = submit_btn.click(fn=run_agent, inputs=run_inputs, outputs=run_outputs)
|
| 273 |
+
submit_event.then(fn=lambda: "", outputs=[query])
|
| 274 |
+
|
| 275 |
+
query_event = query.submit(fn=run_agent, inputs=run_inputs, outputs=run_outputs)
|
| 276 |
+
query_event.then(fn=lambda: "", outputs=[query])
|
| 277 |
+
|
| 278 |
+
new_research_btn.click(fn=reset_ui, outputs=[query, welcome, trace_out, report_out, files_out])
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _warm_up():
|
| 282 |
+
"""Loads the embedding model in the background so the first search is not slow."""
|
| 283 |
+
try:
|
| 284 |
+
get_embedding_model()
|
| 285 |
+
except Exception as e:
|
| 286 |
+
print(f"[DEBUG WARNING] Embedding model warm-up failed: {e}")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
threading.Thread(target=_warm_up, daemon=True).start()
|
| 291 |
+
app.queue(default_concurrency_limit=3)
|
| 292 |
+
app.launch(inbrowser=not os.getenv("SPACE_ID"), css=custom_css)
|
Researchmind-AI/evaluate.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Batch evaluation of ResearchMind on a list of topics.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python evaluate.py # all topics in evaluation/topics.txt
|
| 5 |
+
python evaluate.py --limit 3 --depth Quick
|
| 6 |
+
python evaluate.py --topics my_topics.txt --pause 15
|
| 7 |
+
|
| 8 |
+
For every topic it runs the full agent pipeline and records the fact-checker's verdicts:
|
| 9 |
+
* support rate - share of cited claims fully supported by the passages they cite
|
| 10 |
+
* first draft vs final - how much the Checker/Reviser loop improved the report
|
| 11 |
+
* citation coverage - share of factual sentences that carry a citation
|
| 12 |
+
Results are written to evaluation/results.json and evaluation/RESULTS.md.
|
| 13 |
+
"""
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import statistics
|
| 18 |
+
import time
|
| 19 |
+
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
|
| 22 |
+
load_dotenv(override=True)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def evaluate_topic(topic: str, depth: str, runner) -> dict:
|
| 26 |
+
row = {"topic": topic, "ok": False}
|
| 27 |
+
try:
|
| 28 |
+
final = None
|
| 29 |
+
for event in runner(topic, "Standard", "English", depth, True, None):
|
| 30 |
+
if event["error"]:
|
| 31 |
+
row["error"] = event["report"]
|
| 32 |
+
return row
|
| 33 |
+
if event["done"]:
|
| 34 |
+
final = event["result"]
|
| 35 |
+
if not final:
|
| 36 |
+
row["error"] = "pipeline produced no result"
|
| 37 |
+
return row
|
| 38 |
+
except Exception as e:
|
| 39 |
+
row["error"] = str(e)
|
| 40 |
+
return row
|
| 41 |
+
|
| 42 |
+
m = final["metrics"]
|
| 43 |
+
first, last = m["first_pass"] or {}, m["final"] or {}
|
| 44 |
+
row.update({
|
| 45 |
+
"ok": True,
|
| 46 |
+
"sources": m["sources"],
|
| 47 |
+
"passages": m["passages_indexed"],
|
| 48 |
+
"claims": last.get("claims"),
|
| 49 |
+
"checked": last.get("checked"),
|
| 50 |
+
"support_first": first.get("support_rate"),
|
| 51 |
+
"support_final": last.get("support_rate"),
|
| 52 |
+
"citation_coverage": last.get("citation_coverage"),
|
| 53 |
+
"revised": m["revised"],
|
| 54 |
+
"seconds": m["seconds"],
|
| 55 |
+
})
|
| 56 |
+
return row
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _mean(rows, key):
|
| 60 |
+
values = [r[key] for r in rows if r.get(key) is not None]
|
| 61 |
+
return statistics.mean(values) if values else None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def aggregate(rows: list) -> dict:
|
| 65 |
+
ok = [r for r in rows if r["ok"]]
|
| 66 |
+
return {
|
| 67 |
+
"topics": len(rows),
|
| 68 |
+
"completed": len(ok),
|
| 69 |
+
"avg_sources": _mean(ok, "sources"),
|
| 70 |
+
"avg_claims": _mean(ok, "claims"),
|
| 71 |
+
"avg_support_first": _mean(ok, "support_first"),
|
| 72 |
+
"avg_support_final": _mean(ok, "support_final"),
|
| 73 |
+
"avg_citation_coverage": _mean(ok, "citation_coverage"),
|
| 74 |
+
"avg_seconds": _mean(ok, "seconds"),
|
| 75 |
+
"revised_count": sum(1 for r in ok if r.get("revised")),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _pct(x):
|
| 80 |
+
return "n/a" if x is None else f"{x * 100:.0f}%"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _num(x, digits=1):
|
| 84 |
+
return "n/a" if x is None else f"{x:.{digits}f}"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def write_outputs(rows: list, summary: dict, out_dir: str, depth: str):
|
| 88 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 89 |
+
with open(os.path.join(out_dir, "results.json"), "w", encoding="utf-8") as f:
|
| 90 |
+
json.dump({"depth": depth, "summary": summary, "rows": rows}, f, indent=2)
|
| 91 |
+
|
| 92 |
+
lines = [
|
| 93 |
+
"# Evaluation results",
|
| 94 |
+
"",
|
| 95 |
+
f"Depth preset: **{depth}** · Topics completed: **{summary['completed']}/{summary['topics']}**",
|
| 96 |
+
"",
|
| 97 |
+
"| Topic | Sources | Claims | Support (1st draft) | Support (final) | Cited sentences | Revised | Time (s) |",
|
| 98 |
+
"|---|---|---|---|---|---|---|---|",
|
| 99 |
+
]
|
| 100 |
+
for r in rows:
|
| 101 |
+
if r["ok"]:
|
| 102 |
+
lines.append(
|
| 103 |
+
f"| {r['topic']} | {r['sources']} | {r['claims']} | {_pct(r['support_first'])} | "
|
| 104 |
+
f"{_pct(r['support_final'])} | {_pct(r['citation_coverage'])} | {'yes' if r['revised'] else 'no'} | {_num(r['seconds'])} |"
|
| 105 |
+
)
|
| 106 |
+
else:
|
| 107 |
+
lines.append(f"| {r['topic']} | failed: {r.get('error', 'unknown')[:60]} | | | | | | |")
|
| 108 |
+
lines += [
|
| 109 |
+
"",
|
| 110 |
+
"## Summary",
|
| 111 |
+
"",
|
| 112 |
+
f"- Average claim support: **{_pct(summary['avg_support_first'])}** in the first draft → **{_pct(summary['avg_support_final'])}** after the Checker/Reviser loop",
|
| 113 |
+
f"- Average share of factual sentences with a citation: **{_pct(summary['avg_citation_coverage'])}**",
|
| 114 |
+
f"- Average sources per report: **{_num(summary['avg_sources'])}**, claims checked per report: **{_num(summary['avg_claims'])}**",
|
| 115 |
+
f"- Reports improved by the Reviser: **{summary['revised_count']}/{summary['completed']}**",
|
| 116 |
+
f"- Average time per report: **{_num(summary['avg_seconds'])} s**",
|
| 117 |
+
"",
|
| 118 |
+
"_Support is judged by an automated LLM fact-checker against the passages each claim cites; "
|
| 119 |
+
"it measures grounding in the retrieved sources, not absolute truth._",
|
| 120 |
+
]
|
| 121 |
+
with open(os.path.join(out_dir, "RESULTS.md"), "w", encoding="utf-8") as f:
|
| 122 |
+
f.write("\n".join(lines) + "\n")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main():
|
| 126 |
+
parser = argparse.ArgumentParser(description="Evaluate ResearchMind on a list of topics.")
|
| 127 |
+
parser.add_argument("--topics", default="evaluation/topics.txt")
|
| 128 |
+
parser.add_argument("--depth", default="Standard", choices=["Quick", "Standard", "Deep"])
|
| 129 |
+
parser.add_argument("--limit", type=int, default=None, help="only run the first N topics")
|
| 130 |
+
parser.add_argument("--pause", type=float, default=8.0, help="seconds to wait between topics (API rate limits)")
|
| 131 |
+
parser.add_argument("--out", default="evaluation")
|
| 132 |
+
args = parser.parse_args()
|
| 133 |
+
|
| 134 |
+
from src.pipeline import run_research
|
| 135 |
+
|
| 136 |
+
with open(args.topics, encoding="utf-8") as f:
|
| 137 |
+
topics = [line.strip() for line in f if line.strip()]
|
| 138 |
+
if args.limit:
|
| 139 |
+
topics = topics[: args.limit]
|
| 140 |
+
|
| 141 |
+
rows = []
|
| 142 |
+
for i, topic in enumerate(topics, 1):
|
| 143 |
+
print(f"[{i}/{len(topics)}] {topic}")
|
| 144 |
+
row = evaluate_topic(topic, args.depth, run_research)
|
| 145 |
+
print(" ->", f"support {_pct(row['support_first'])} -> {_pct(row['support_final'])}, {row['seconds']}s" if row["ok"] else f"FAILED: {row.get('error')}")
|
| 146 |
+
rows.append(row)
|
| 147 |
+
if i < len(topics):
|
| 148 |
+
time.sleep(args.pause)
|
| 149 |
+
|
| 150 |
+
summary = aggregate(rows)
|
| 151 |
+
write_outputs(rows, summary, args.out, args.depth)
|
| 152 |
+
print(f"\nWrote {args.out}/results.json and {args.out}/RESULTS.md")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
main()
|
Researchmind-AI/evaluation/topics.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
How is generative AI being used in agriculture
|
| 2 |
+
Impact of large language models on software engineering jobs
|
| 3 |
+
Retrieval-augmented generation techniques to reduce LLM hallucinations
|
| 4 |
+
Solar energy adoption in Pakistan
|
| 5 |
+
Current state of AI regulation in the European Union
|
| 6 |
+
Benefits and risks of AI in healthcare diagnostics
|
| 7 |
+
How do AI agents use tools and planning
|
| 8 |
+
Electric vehicle adoption trends worldwide
|
| 9 |
+
Cybersecurity risks of generative AI
|
| 10 |
+
Future of remote work after 2025
|
Researchmind-AI/pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
| 3 |
+
testpaths = tests
|
Researchmind-AI/requirements-dev.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest>=8.0
|
Researchmind-AI/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.0.0,<7
|
| 2 |
+
groq>=0.4.0,<2
|
| 3 |
+
python-dotenv>=1.0.0,<2
|
| 4 |
+
tavily-python>=0.3.0,<1
|
| 5 |
+
sentence-transformers>=2.2.2,<7
|
| 6 |
+
faiss-cpu>=1.7.4,<2
|
| 7 |
+
langchain-text-splitters>=0.0.1,<2
|
| 8 |
+
reportlab>=4.0.0,<6
|
| 9 |
+
pypdf>=4.0.0,<7
|
| 10 |
+
numpy>=1.24.0,<3
|
Researchmind-AI/src/agents.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The agents of ResearchMind. Each one is a small, testable function.
|
| 2 |
+
|
| 3 |
+
Planner -> splits the topic into sub-questions
|
| 4 |
+
Researcher -> searches the web for every sub-question (in parallel)
|
| 5 |
+
Writer -> drafts a cited report from retrieved evidence
|
| 6 |
+
Checker -> verifies every cited claim against the passages it cites
|
| 7 |
+
Reviser -> rewrites flagged claims so they match the evidence
|
| 8 |
+
Translator -> renders the verified report in another language
|
| 9 |
+
"""
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
from collections import Counter
|
| 13 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 14 |
+
|
| 15 |
+
from src import config
|
| 16 |
+
from src.llm import LLMError
|
| 17 |
+
from src.prompts import (
|
| 18 |
+
SYSTEM_CHECKER,
|
| 19 |
+
SYSTEM_PLANNER,
|
| 20 |
+
SYSTEM_REVISER,
|
| 21 |
+
SYSTEM_TRANSLATOR,
|
| 22 |
+
SYSTEM_WRITER,
|
| 23 |
+
build_check_prompt,
|
| 24 |
+
build_planner_prompt,
|
| 25 |
+
build_revise_prompt,
|
| 26 |
+
build_translate_prompt,
|
| 27 |
+
build_writer_prompt,
|
| 28 |
+
)
|
| 29 |
+
from src.search import SearchError
|
| 30 |
+
|
| 31 |
+
_CITATION = re.compile(r"\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]")
|
| 32 |
+
VERDICTS = ("supported", "partial", "unsupported")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ------------------------------------------------------------------ helpers
|
| 36 |
+
def parse_json(text: str):
|
| 37 |
+
"""Parses JSON even if the model wrapped it in prose or code fences."""
|
| 38 |
+
if not text:
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
return json.loads(text)
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
match = re.search(r"\{.*\}", text, re.S)
|
| 45 |
+
if match:
|
| 46 |
+
try:
|
| 47 |
+
return json.loads(match.group(0))
|
| 48 |
+
except Exception:
|
| 49 |
+
return None
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def clean_citations(text: str, max_n: int) -> str:
|
| 54 |
+
"""Normalises [1, 2] to [1][2] and drops citation numbers the model invented (outside 1..max_n)."""
|
| 55 |
+
def _replace(match):
|
| 56 |
+
numbers = [int(n) for n in re.split(r"\s*,\s*", match.group(1))]
|
| 57 |
+
return "".join(f"[{n}]" for n in numbers if 1 <= n <= max_n)
|
| 58 |
+
|
| 59 |
+
return _CITATION.sub(_replace, text)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ------------------------------------------------------------------ Planner
|
| 63 |
+
def plan(topic: str, n: int, complete) -> list:
|
| 64 |
+
"""Returns up to n search-friendly sub-questions; falls back to the topic itself."""
|
| 65 |
+
messages = [
|
| 66 |
+
{"role": "system", "content": SYSTEM_PLANNER},
|
| 67 |
+
{"role": "user", "content": build_planner_prompt(topic, n)},
|
| 68 |
+
]
|
| 69 |
+
data = parse_json(complete(messages, role="fast", temperature=0.2, json_mode=True))
|
| 70 |
+
questions = []
|
| 71 |
+
for q in (data or {}).get("sub_questions", []):
|
| 72 |
+
if isinstance(q, str) and q.strip() and q.strip() not in questions:
|
| 73 |
+
questions.append(q.strip())
|
| 74 |
+
return questions[:n] or [topic]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# --------------------------------------------------------------- Researcher
|
| 78 |
+
def research_web(questions: list, cfg: dict, searcher) -> tuple:
|
| 79 |
+
"""Searches every sub-question in parallel. Returns (unique_sources, error_messages)."""
|
| 80 |
+
def one(question):
|
| 81 |
+
try:
|
| 82 |
+
return searcher(
|
| 83 |
+
question,
|
| 84 |
+
max_results=cfg["results_per_question"],
|
| 85 |
+
search_depth=cfg["search_depth"],
|
| 86 |
+
), None
|
| 87 |
+
except SearchError as e:
|
| 88 |
+
return [], str(e)
|
| 89 |
+
|
| 90 |
+
sources, errors, seen = [], [], set()
|
| 91 |
+
with ThreadPoolExecutor(max_workers=min(len(questions), 5) or 1) as pool:
|
| 92 |
+
for results, error in pool.map(one, questions):
|
| 93 |
+
if error and error not in errors:
|
| 94 |
+
errors.append(error)
|
| 95 |
+
for item in results:
|
| 96 |
+
key = item.get("url") or item["title"]
|
| 97 |
+
if key in seen:
|
| 98 |
+
continue
|
| 99 |
+
seen.add(key)
|
| 100 |
+
sources.append({**item, "kind": "web"})
|
| 101 |
+
return sources, errors
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ------------------------------------------------------------------- Writer
|
| 105 |
+
def format_context(selected: list, sources: list) -> str:
|
| 106 |
+
"""Renders retrieved passages grouped under their numbered source."""
|
| 107 |
+
blocks = []
|
| 108 |
+
for source in sources:
|
| 109 |
+
texts = [c["text"] for c in selected if c["cite"] == source["n"]]
|
| 110 |
+
body = "\n".join(f"- {t}" for t in texts)
|
| 111 |
+
where = source["url"] if source.get("url") else "uploaded document"
|
| 112 |
+
blocks.append(f"[{source['n']}] {source['title']} ({where})\n{body}")
|
| 113 |
+
return "\n\n".join(blocks)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def write_stream(topic, mode, questions, selected, sources, stream):
|
| 117 |
+
"""Yields text deltas of the first draft."""
|
| 118 |
+
messages = [
|
| 119 |
+
{"role": "system", "content": SYSTEM_WRITER},
|
| 120 |
+
{
|
| 121 |
+
"role": "user",
|
| 122 |
+
"content": build_writer_prompt(topic, mode, questions, format_context(selected, sources), len(sources)),
|
| 123 |
+
},
|
| 124 |
+
]
|
| 125 |
+
yield from stream(messages, role="writer", temperature=0.3)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------ Checker
|
| 129 |
+
def extract_claims(report: str) -> tuple:
|
| 130 |
+
"""Splits a report into cited sentences (claims to verify) and counts uncited factual sentences."""
|
| 131 |
+
claims, uncited = [], 0
|
| 132 |
+
for raw in report.split("\n"):
|
| 133 |
+
line = raw.strip()
|
| 134 |
+
if not line or line.startswith(("#", ">", "---")):
|
| 135 |
+
continue
|
| 136 |
+
line = re.sub(r"^([-*\u2022]|\d+[.)])\s+", "", line)
|
| 137 |
+
line = re.sub(r"\*\*|__", "", line)
|
| 138 |
+
# "claim. [1]" -> "claim[1]." so the citation stays attached to its sentence
|
| 139 |
+
line = re.sub(r"([.!?])\s*((?:\[\d{1,2}\])+)", r"\2\1", line)
|
| 140 |
+
for sentence in re.split(r"(?<=[.!?])\s+", line):
|
| 141 |
+
sentence = sentence.strip()
|
| 142 |
+
cites = sorted({int(n) for n in re.findall(r"\[(\d{1,2})\]", sentence)})
|
| 143 |
+
if cites:
|
| 144 |
+
claims.append({"id": len(claims) + 1, "text": sentence, "cites": cites})
|
| 145 |
+
elif len(sentence.split()) >= 8:
|
| 146 |
+
uncited += 1
|
| 147 |
+
return claims, uncited
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def check_claims(claims: list, store, complete) -> dict:
|
| 151 |
+
"""Verifies claims in small batches against the passages they cite. Returns {claim_id: {verdict, reason}}."""
|
| 152 |
+
results = {}
|
| 153 |
+
to_check = claims[: config.MAX_CLAIMS_CHECKED]
|
| 154 |
+
|
| 155 |
+
for start in range(0, len(to_check), config.CHECK_BATCH_SIZE):
|
| 156 |
+
batch = to_check[start : start + config.CHECK_BATCH_SIZE]
|
| 157 |
+
items = [
|
| 158 |
+
{"id": c["id"], "text": c["text"], "evidence": store.support_for(c["text"], c["cites"], k=2)}
|
| 159 |
+
for c in batch
|
| 160 |
+
]
|
| 161 |
+
messages = [
|
| 162 |
+
{"role": "system", "content": SYSTEM_CHECKER},
|
| 163 |
+
{"role": "user", "content": build_check_prompt(items)},
|
| 164 |
+
]
|
| 165 |
+
try:
|
| 166 |
+
data = parse_json(complete(messages, role="fast", temperature=0.0, json_mode=True))
|
| 167 |
+
except LLMError as e:
|
| 168 |
+
print(f"[DEBUG WARNING] Checker batch skipped: {e}")
|
| 169 |
+
continue
|
| 170 |
+
for entry in (data or {}).get("results", []):
|
| 171 |
+
try:
|
| 172 |
+
claim_id = int(entry["id"])
|
| 173 |
+
except (KeyError, TypeError, ValueError):
|
| 174 |
+
continue
|
| 175 |
+
verdict = str(entry.get("verdict", "")).lower().strip()
|
| 176 |
+
if verdict in VERDICTS and any(c["id"] == claim_id for c in batch):
|
| 177 |
+
results[claim_id] = {"verdict": verdict, "reason": str(entry.get("reason", ""))[:200]}
|
| 178 |
+
return results
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def summarize_check(claims: list, results: dict, uncited: int) -> dict:
|
| 182 |
+
counts = Counter(results[c["id"]]["verdict"] for c in claims if c["id"] in results)
|
| 183 |
+
checked = sum(counts.values())
|
| 184 |
+
total_sentences = len(claims) + uncited
|
| 185 |
+
return {
|
| 186 |
+
"claims": len(claims),
|
| 187 |
+
"checked": checked,
|
| 188 |
+
"supported": counts["supported"],
|
| 189 |
+
"partial": counts["partial"],
|
| 190 |
+
"unsupported": counts["unsupported"],
|
| 191 |
+
"support_rate": (counts["supported"] / checked) if checked else None,
|
| 192 |
+
"citation_coverage": (len(claims) / total_sentences) if total_sentences else None,
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ------------------------------------------------------------------ Reviser
|
| 197 |
+
def revise(report: str, claims: list, results: dict, store, complete) -> str:
|
| 198 |
+
"""Rewrites the report so flagged claims match their evidence. Returns the original if the rewrite looks broken."""
|
| 199 |
+
issues = []
|
| 200 |
+
for claim in claims:
|
| 201 |
+
verdict = results.get(claim["id"])
|
| 202 |
+
if verdict and verdict["verdict"] in ("unsupported", "partial"):
|
| 203 |
+
issues.append({
|
| 204 |
+
"text": claim["text"],
|
| 205 |
+
"verdict": verdict["verdict"],
|
| 206 |
+
"reason": verdict["reason"] or "not backed by the cited source",
|
| 207 |
+
"evidence": store.support_for(claim["text"], claim["cites"], k=2),
|
| 208 |
+
})
|
| 209 |
+
if not issues:
|
| 210 |
+
return report
|
| 211 |
+
|
| 212 |
+
messages = [
|
| 213 |
+
{"role": "system", "content": SYSTEM_REVISER},
|
| 214 |
+
{"role": "user", "content": build_revise_prompt(report, issues)},
|
| 215 |
+
]
|
| 216 |
+
revised = complete(messages, role="writer", temperature=0.1).strip()
|
| 217 |
+
# Guard against a stub or truncated answer replacing the whole report
|
| 218 |
+
if len(revised) < 0.5 * len(report):
|
| 219 |
+
return report
|
| 220 |
+
return revised
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# --------------------------------------------------------------- Translator
|
| 224 |
+
def translate(report: str, language: str, complete) -> str:
|
| 225 |
+
messages = [
|
| 226 |
+
{"role": "system", "content": SYSTEM_TRANSLATOR},
|
| 227 |
+
{"role": "user", "content": build_translate_prompt(report, language)},
|
| 228 |
+
]
|
| 229 |
+
translated = complete(messages, role="writer", temperature=0.1).strip()
|
| 230 |
+
if len(translated) < 0.3 * len(report):
|
| 231 |
+
raise LLMError("The translation came back too short.")
|
| 232 |
+
return translated
|
Researchmind-AI/src/chunking.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def split_text(text: str, chunk_size: int = 500, chunk_overlap: int = 100) -> list:
|
| 5 |
+
"""Splits raw input text into overlapping chunks using LangChain's RecursiveCharacterTextSplitter."""
|
| 6 |
+
if not text or not isinstance(text, str) or not text.strip():
|
| 7 |
+
print("[DEBUG WARNING] Empty or non-string input passed to split_text.")
|
| 8 |
+
return []
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 12 |
+
chunk_size=chunk_size,
|
| 13 |
+
chunk_overlap=chunk_overlap,
|
| 14 |
+
separators=["\n\n", "\n", " ", ""]
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
chunks = splitter.split_text(text)
|
| 18 |
+
return chunks
|
| 19 |
+
|
| 20 |
+
except Exception as e:
|
| 21 |
+
print(f"[DEBUG ERROR] Failed to split text: {e}")
|
| 22 |
+
return []
|
Researchmind-AI/src/config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central settings for ResearchMind. Tune the pipeline here."""
|
| 2 |
+
|
| 3 |
+
# --- chunking -------------------------------------------------------------
|
| 4 |
+
CHUNK_SIZE = 800
|
| 5 |
+
CHUNK_OVERLAP = 120
|
| 6 |
+
|
| 7 |
+
# --- research depth presets (shown in the UI) -----------------------------
|
| 8 |
+
DEPTH_PRESETS = {
|
| 9 |
+
"Quick": {"sub_questions": 2, "results_per_question": 3, "search_depth": "basic", "chunks_per_question": 4},
|
| 10 |
+
"Standard": {"sub_questions": 3, "results_per_question": 4, "search_depth": "advanced", "chunks_per_question": 5},
|
| 11 |
+
"Deep": {"sub_questions": 5, "results_per_question": 4, "search_depth": "advanced", "chunks_per_question": 5},
|
| 12 |
+
}
|
| 13 |
+
DEFAULT_DEPTH = "Standard"
|
| 14 |
+
|
| 15 |
+
# --- evidence selection ---------------------------------------------------
|
| 16 |
+
MAX_EVIDENCE_CHUNKS = 20 # total passages given to the Writer
|
| 17 |
+
MAX_CHUNKS_PER_SOURCE = 4 # across the whole report, so no single page dominates
|
| 18 |
+
MAX_PER_SOURCE_PER_QUESTION = 2 # per sub-question
|
| 19 |
+
MIN_SCORE = 0.2 # cosine floor for a passage to count as relevant
|
| 20 |
+
MIN_KEEP_PER_QUESTION = 2 # always keep this many passages per sub-question
|
| 21 |
+
|
| 22 |
+
# --- fact-checking --------------------------------------------------------
|
| 23 |
+
CHECK_BATCH_SIZE = 5
|
| 24 |
+
MAX_CLAIMS_CHECKED = 40
|
| 25 |
+
MAX_REVISION_ROUNDS = 1
|
| 26 |
+
|
| 27 |
+
# --- UI options -----------------------------------------------------------
|
| 28 |
+
MODES = ["Standard", "Summary", "Academic"]
|
| 29 |
+
LANGUAGES = ["English", "Urdu"]
|
| 30 |
+
|
| 31 |
+
# --- uploaded documents ---------------------------------------------------
|
| 32 |
+
MAX_UPLOAD_FILES = 5
|
| 33 |
+
MAX_CHARS_PER_DOCUMENT = 150_000
|
Researchmind-AI/src/documents.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
from src.config import MAX_CHARS_PER_DOCUMENT, MAX_UPLOAD_FILES
|
| 5 |
+
|
| 6 |
+
SUPPORTED = (".pdf", ".txt", ".md")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _read_pdf(path: str) -> str:
|
| 10 |
+
from pypdf import PdfReader
|
| 11 |
+
|
| 12 |
+
reader = PdfReader(path)
|
| 13 |
+
return "\n\n".join((page.extract_text() or "") for page in reader.pages)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _read_text(path: str) -> str:
|
| 17 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
| 18 |
+
return f.read()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_documents(paths) -> tuple:
|
| 22 |
+
"""Reads uploaded PDF/TXT/MD files into source dicts.
|
| 23 |
+
|
| 24 |
+
Returns (documents, warnings). Each document is {title, url, content, kind}.
|
| 25 |
+
Problems with one file never stop the others.
|
| 26 |
+
"""
|
| 27 |
+
documents, warnings = [], []
|
| 28 |
+
paths = [getattr(p, "name", p) for p in (paths or [])]
|
| 29 |
+
|
| 30 |
+
if len(paths) > MAX_UPLOAD_FILES:
|
| 31 |
+
warnings.append(f"Only the first {MAX_UPLOAD_FILES} files are used.")
|
| 32 |
+
paths = paths[:MAX_UPLOAD_FILES]
|
| 33 |
+
|
| 34 |
+
for path in paths:
|
| 35 |
+
name = os.path.basename(path)
|
| 36 |
+
ext = os.path.splitext(name)[1].lower()
|
| 37 |
+
if ext not in SUPPORTED:
|
| 38 |
+
warnings.append(f"`{name}` skipped: unsupported file type.")
|
| 39 |
+
continue
|
| 40 |
+
try:
|
| 41 |
+
text = _read_pdf(path) if ext == ".pdf" else _read_text(path)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
warnings.append(f"`{name}` could not be read: {e}")
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 47 |
+
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 48 |
+
if len(text) < 50:
|
| 49 |
+
warnings.append(f"`{name}` has no readable text (a scanned PDF needs OCR first).")
|
| 50 |
+
continue
|
| 51 |
+
if len(text) > MAX_CHARS_PER_DOCUMENT:
|
| 52 |
+
text = text[:MAX_CHARS_PER_DOCUMENT]
|
| 53 |
+
warnings.append(f"`{name}` is long; only the first {MAX_CHARS_PER_DOCUMENT:,} characters are used.")
|
| 54 |
+
|
| 55 |
+
documents.append({"title": name, "url": "", "content": text, "kind": "document"})
|
| 56 |
+
|
| 57 |
+
return documents, warnings
|
Researchmind-AI/src/embeddings.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import faiss
|
| 3 |
+
|
| 4 |
+
# Cache model in memory globally so it only loads once
|
| 5 |
+
_MODEL_CACHE = None
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_embedding_model(model_name: str = "all-MiniLM-L6-v2"):
|
| 9 |
+
"""Loads and caches the SentenceTransformer model (imported lazily to keep startup fast)."""
|
| 10 |
+
global _MODEL_CACHE
|
| 11 |
+
if _MODEL_CACHE is None:
|
| 12 |
+
from sentence_transformers import SentenceTransformer
|
| 13 |
+
|
| 14 |
+
print(f"Loading embedding model '{model_name}'...")
|
| 15 |
+
_MODEL_CACHE = SentenceTransformer(model_name)
|
| 16 |
+
return _MODEL_CACHE
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def embed_texts(texts: list, model=None) -> np.ndarray:
|
| 20 |
+
"""Encodes texts into L2-normalized float32 vectors (so inner product == cosine similarity)."""
|
| 21 |
+
model = model or get_embedding_model()
|
| 22 |
+
vectors = model.encode(
|
| 23 |
+
texts,
|
| 24 |
+
convert_to_numpy=True,
|
| 25 |
+
normalize_embeddings=True,
|
| 26 |
+
show_progress_bar=False,
|
| 27 |
+
)
|
| 28 |
+
return np.asarray(vectors, dtype="float32")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def build_cosine_index(vectors: np.ndarray) -> faiss.IndexFlatIP:
|
| 32 |
+
"""Builds an in-memory FAISS inner-product index over normalized vectors."""
|
| 33 |
+
index = faiss.IndexFlatIP(vectors.shape[1])
|
| 34 |
+
index.add(vectors)
|
| 35 |
+
return index
|
Researchmind-AI/src/evidence.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
from src import config
|
| 4 |
+
from src.chunking import split_text
|
| 5 |
+
from src.embeddings import build_cosine_index, embed_texts
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class EvidenceError(Exception):
|
| 9 |
+
"""Raised when there is no readable text to build evidence from."""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EvidenceStore:
|
| 13 |
+
"""All source text, chunked and indexed in an in-memory FAISS cosine index.
|
| 14 |
+
|
| 15 |
+
Used three ways: retrieve passages per sub-question for the Writer (`gather`),
|
| 16 |
+
and pull the passages that back a specific claim for the Checker (`support_for`).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, sources: list, embedder=None):
|
| 20 |
+
self.sources = sources
|
| 21 |
+
self.embedder = embedder
|
| 22 |
+
self.records = []
|
| 23 |
+
for source_idx, source in enumerate(sources):
|
| 24 |
+
for piece in split_text(source["content"], config.CHUNK_SIZE, config.CHUNK_OVERLAP):
|
| 25 |
+
self.records.append({"id": len(self.records), "text": piece, "source_idx": source_idx})
|
| 26 |
+
if not self.records:
|
| 27 |
+
raise EvidenceError("The sources contained no readable text.")
|
| 28 |
+
|
| 29 |
+
self.vectors = embed_texts([r["text"] for r in self.records], embedder)
|
| 30 |
+
self.index = build_cosine_index(self.vectors)
|
| 31 |
+
self.numbering = {} # source_idx -> citation number, assigned by gather()
|
| 32 |
+
self.selected = []
|
| 33 |
+
|
| 34 |
+
def _search(self, query: str, k: int):
|
| 35 |
+
query_vector = embed_texts([query], self.embedder)
|
| 36 |
+
scores, ids = self.index.search(query_vector, min(len(self.records), k))
|
| 37 |
+
return [(self.records[i], float(s)) for s, i in zip(scores[0], ids[0]) if i >= 0]
|
| 38 |
+
|
| 39 |
+
def gather(self, questions: list, chunks_per_question: int) -> tuple:
|
| 40 |
+
"""Selects the best passages for each sub-question; returns (selected_chunks, numbered_sources)."""
|
| 41 |
+
chosen, chosen_ids, per_source_total = [], set(), {}
|
| 42 |
+
|
| 43 |
+
for q_idx, question in enumerate(questions):
|
| 44 |
+
kept, per_source_q = 0, {}
|
| 45 |
+
for record, score in self._search(question, chunks_per_question * 6):
|
| 46 |
+
if kept >= chunks_per_question or len(chosen) >= config.MAX_EVIDENCE_CHUNKS:
|
| 47 |
+
break
|
| 48 |
+
sid = record["source_idx"]
|
| 49 |
+
if per_source_q.get(sid, 0) >= config.MAX_PER_SOURCE_PER_QUESTION:
|
| 50 |
+
continue
|
| 51 |
+
if record["id"] in chosen_ids:
|
| 52 |
+
# Already picked for an earlier question: it still covers this one, so count it
|
| 53 |
+
# instead of padding this question with weaker passages.
|
| 54 |
+
if score >= config.MIN_SCORE:
|
| 55 |
+
per_source_q[sid] = per_source_q.get(sid, 0) + 1
|
| 56 |
+
kept += 1
|
| 57 |
+
continue
|
| 58 |
+
if per_source_total.get(sid, 0) >= config.MAX_CHUNKS_PER_SOURCE:
|
| 59 |
+
continue
|
| 60 |
+
if score < config.MIN_SCORE and kept >= config.MIN_KEEP_PER_QUESTION:
|
| 61 |
+
continue
|
| 62 |
+
chosen_ids.add(record["id"])
|
| 63 |
+
chosen.append({**record, "score": score, "question_idx": q_idx})
|
| 64 |
+
per_source_q[sid] = per_source_q.get(sid, 0) + 1
|
| 65 |
+
per_source_total[sid] = per_source_total.get(sid, 0) + 1
|
| 66 |
+
kept += 1
|
| 67 |
+
|
| 68 |
+
if not chosen:
|
| 69 |
+
raise EvidenceError("No relevant passages were found in the sources.")
|
| 70 |
+
|
| 71 |
+
# Number sources by first appearance so citations read [1], [2], [3]... in order
|
| 72 |
+
self.numbering = {}
|
| 73 |
+
for chunk in chosen:
|
| 74 |
+
self.numbering.setdefault(chunk["source_idx"], len(self.numbering) + 1)
|
| 75 |
+
for chunk in chosen:
|
| 76 |
+
chunk["cite"] = self.numbering[chunk["source_idx"]]
|
| 77 |
+
self.selected = chosen
|
| 78 |
+
|
| 79 |
+
numbered = [
|
| 80 |
+
{
|
| 81 |
+
"n": n,
|
| 82 |
+
"title": self.sources[sid]["title"],
|
| 83 |
+
"url": self.sources[sid].get("url", ""),
|
| 84 |
+
"kind": self.sources[sid].get("kind", "web"),
|
| 85 |
+
}
|
| 86 |
+
for sid, n in sorted(self.numbering.items(), key=lambda kv: kv[1])
|
| 87 |
+
]
|
| 88 |
+
return chosen, numbered
|
| 89 |
+
|
| 90 |
+
def support_for(self, claim: str, cites: list, k: int = 2) -> list:
|
| 91 |
+
"""The k passages from the cited sources that are closest to a claim (what the Checker reads)."""
|
| 92 |
+
wanted = {sid for sid, n in self.numbering.items() if n in set(cites)}
|
| 93 |
+
candidates = [r["id"] for r in self.records if r["source_idx"] in wanted]
|
| 94 |
+
if not candidates:
|
| 95 |
+
return []
|
| 96 |
+
claim_vector = embed_texts([claim], self.embedder)[0]
|
| 97 |
+
sims = self.vectors[candidates] @ claim_vector
|
| 98 |
+
best = np.argsort(-sims)[:k]
|
| 99 |
+
return [self.records[candidates[i]]["text"] for i in best]
|
Researchmind-AI/src/llm.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv(override=True)
|
| 7 |
+
|
| 8 |
+
# Each agent role has its own model preference list. Later entries are used if a model is
|
| 9 |
+
# unavailable or rate-limited. Override the first choice with GROQ_WRITER_MODEL / GROQ_FAST_MODEL.
|
| 10 |
+
DEFAULT_MODELS = {
|
| 11 |
+
"writer": ["llama-3.3-70b-versatile", "openai/gpt-oss-120b", "llama-3.1-8b-instant"],
|
| 12 |
+
"fast": ["llama-3.1-8b-instant", "openai/gpt-oss-20b", "llama-3.3-70b-versatile"],
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class LLMError(Exception):
|
| 17 |
+
"""Raised when no Groq model could produce an answer."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def model_candidates(role: str) -> list:
|
| 21 |
+
models = list(DEFAULT_MODELS[role])
|
| 22 |
+
override = os.getenv(f"GROQ_{role.upper()}_MODEL", "").strip()
|
| 23 |
+
if override:
|
| 24 |
+
models = [override] + [m for m in models if m != override]
|
| 25 |
+
return models
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_client():
|
| 29 |
+
"""Creates the Groq client lazily so a missing key is a clear runtime message, not an import crash."""
|
| 30 |
+
key = os.getenv("GROQ_API_KEY", "").strip().strip('"').strip("'")
|
| 31 |
+
if not key:
|
| 32 |
+
raise LLMError("`GROQ_API_KEY` is missing in your `.env` file (or Space secrets).")
|
| 33 |
+
from groq import Groq
|
| 34 |
+
|
| 35 |
+
return Groq(api_key=key)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _is_rate_limit(error) -> bool:
|
| 39 |
+
text = str(error).lower()
|
| 40 |
+
return "429" in text or "rate limit" in text or "rate_limit" in text
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def complete(messages: list, role: str = "fast", temperature: float = 0.0, json_mode: bool = False) -> str:
|
| 44 |
+
"""Single (non-streaming) completion with model fallback and one patient retry on rate limits."""
|
| 45 |
+
client = get_client()
|
| 46 |
+
last_error = None
|
| 47 |
+
|
| 48 |
+
for attempt in range(2):
|
| 49 |
+
for model_id in model_candidates(role):
|
| 50 |
+
kwargs = {"model": model_id, "messages": messages, "temperature": temperature}
|
| 51 |
+
try:
|
| 52 |
+
if json_mode:
|
| 53 |
+
try:
|
| 54 |
+
response = client.chat.completions.create(**kwargs, response_format={"type": "json_object"})
|
| 55 |
+
except Exception as json_error:
|
| 56 |
+
if _is_rate_limit(json_error):
|
| 57 |
+
raise
|
| 58 |
+
response = client.chat.completions.create(**kwargs)
|
| 59 |
+
else:
|
| 60 |
+
response = client.chat.completions.create(**kwargs)
|
| 61 |
+
return response.choices[0].message.content or ""
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"[DEBUG NOTICE] Groq model '{model_id}' failed: {e}")
|
| 64 |
+
last_error = e
|
| 65 |
+
if attempt == 0 and last_error is not None and _is_rate_limit(last_error):
|
| 66 |
+
time.sleep(8)
|
| 67 |
+
continue
|
| 68 |
+
break
|
| 69 |
+
|
| 70 |
+
raise LLMError(f"All Groq models failed. Last error: `{last_error}`")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def stream_completion(messages: list, role: str = "writer", temperature: float = 0.3):
|
| 74 |
+
"""Yields text deltas, falling back to the next model if one fails before producing output."""
|
| 75 |
+
client = get_client()
|
| 76 |
+
last_error = None
|
| 77 |
+
|
| 78 |
+
for attempt in range(2):
|
| 79 |
+
for model_id in model_candidates(role):
|
| 80 |
+
started = False
|
| 81 |
+
try:
|
| 82 |
+
stream = client.chat.completions.create(
|
| 83 |
+
model=model_id, messages=messages, temperature=temperature, stream=True
|
| 84 |
+
)
|
| 85 |
+
for chunk in stream:
|
| 86 |
+
delta = chunk.choices[0].delta.content if chunk.choices else None
|
| 87 |
+
if delta:
|
| 88 |
+
started = True
|
| 89 |
+
yield delta
|
| 90 |
+
return
|
| 91 |
+
except Exception as e:
|
| 92 |
+
if started:
|
| 93 |
+
# Failed midway: switching models would restart the report, so surface the error.
|
| 94 |
+
raise LLMError(f"Generation was interrupted: `{e}`")
|
| 95 |
+
print(f"[DEBUG NOTICE] Groq model '{model_id}' failed: {e}")
|
| 96 |
+
last_error = e
|
| 97 |
+
if attempt == 0 and last_error is not None and _is_rate_limit(last_error):
|
| 98 |
+
time.sleep(8)
|
| 99 |
+
continue
|
| 100 |
+
break
|
| 101 |
+
|
| 102 |
+
raise LLMError(f"All Groq models failed. Last error: `{last_error}`")
|
Researchmind-AI/src/pipeline.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Orchestrates the agents: Planner -> Researcher -> Retriever -> Writer -> Checker -> Reviser -> Translator."""
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import types
|
| 5 |
+
|
| 6 |
+
from src import agents, config, llm
|
| 7 |
+
from src.documents import load_documents
|
| 8 |
+
from src.evidence import EvidenceError, EvidenceStore
|
| 9 |
+
from src.search import search_topic
|
| 10 |
+
|
| 11 |
+
DEFAULT_LLM = types.SimpleNamespace(complete=llm.complete, stream=llm.stream_completion)
|
| 12 |
+
|
| 13 |
+
_ICONS = {"run": "⏳", "ok": "✅", "warn": "⚠️", "fail": "❌"}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Trace:
|
| 17 |
+
"""The live 'agent activity' log shown in the UI."""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.steps = []
|
| 21 |
+
|
| 22 |
+
def start(self, name: str, detail: str = "") -> int:
|
| 23 |
+
self.steps.append({"name": name, "status": "run", "detail": detail, "items": []})
|
| 24 |
+
return len(self.steps) - 1
|
| 25 |
+
|
| 26 |
+
def finish(self, idx: int, detail: str = None, status: str = "ok", items: list = None):
|
| 27 |
+
step = self.steps[idx]
|
| 28 |
+
step["status"] = status
|
| 29 |
+
if detail is not None:
|
| 30 |
+
step["detail"] = detail
|
| 31 |
+
if items:
|
| 32 |
+
step["items"] = items
|
| 33 |
+
|
| 34 |
+
def render(self) -> str:
|
| 35 |
+
lines = ["### 🤖 Agent activity", ""]
|
| 36 |
+
for s in self.steps:
|
| 37 |
+
lines.append(f"{_ICONS[s['status']]} **{s['name']}** — {s['detail']}".rstrip(" —"))
|
| 38 |
+
for item in s["items"]:
|
| 39 |
+
lines.append(f" - {item}")
|
| 40 |
+
return "\n".join(lines)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _event(trace, report=None, done=False, error=False, result=None):
|
| 44 |
+
return {"trace": trace.render(), "report": report, "done": done, "error": error, "result": result}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def sources_markdown(sources: list) -> str:
|
| 48 |
+
lines = []
|
| 49 |
+
for s in sources:
|
| 50 |
+
if s.get("kind") == "document":
|
| 51 |
+
lines.append(f"{s['n']}. 📄 **{s['title']}** (uploaded document)")
|
| 52 |
+
else:
|
| 53 |
+
title = re.sub(r"[\[\]]", "", s["title"]) or s["url"]
|
| 54 |
+
lines.append(f"{s['n']}. [{title}]({s['url']})")
|
| 55 |
+
return "\n\n---\n\n## Sources\n\n" + "\n".join(lines)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def verification_markdown(final: dict, first: dict, revised: bool) -> str:
|
| 59 |
+
if not final or not final["checked"]:
|
| 60 |
+
return "\n\n> 🔎 **Verification:** the automatic fact-check was unavailable for this report. Please verify key facts yourself."
|
| 61 |
+
text = f"{final['supported']} of {final['checked']} cited claims are fully supported by their sources"
|
| 62 |
+
extras = []
|
| 63 |
+
if final["partial"]:
|
| 64 |
+
extras.append(f"{final['partial']} partially supported")
|
| 65 |
+
if final["unsupported"]:
|
| 66 |
+
extras.append(f"{final['unsupported']} unsupported")
|
| 67 |
+
if extras:
|
| 68 |
+
text += " (" + ", ".join(extras) + ")"
|
| 69 |
+
if revised and first and first["checked"]:
|
| 70 |
+
text += f". The first draft scored {first['supported']}/{first['checked']}; the Reviser agent corrected the rest"
|
| 71 |
+
return f"\n\n> 🔎 **Verification:** {text}. This is an automated check — verify critical facts yourself."
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _check(report, store, complete):
|
| 75 |
+
claims, uncited = agents.extract_claims(report)
|
| 76 |
+
results = agents.check_claims(claims, store, complete) if claims else {}
|
| 77 |
+
return claims, results, agents.summarize_check(claims, results, uncited)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def run_research(
|
| 81 |
+
topic: str,
|
| 82 |
+
mode: str = "Standard",
|
| 83 |
+
language: str = "English",
|
| 84 |
+
depth: str = config.DEFAULT_DEPTH,
|
| 85 |
+
use_web: bool = True,
|
| 86 |
+
document_paths=None,
|
| 87 |
+
*,
|
| 88 |
+
searcher=None,
|
| 89 |
+
embedder=None,
|
| 90 |
+
llm_api=None,
|
| 91 |
+
):
|
| 92 |
+
"""Generator. Yields {"trace", "report", "done", "error", "result"} events as the agents work.
|
| 93 |
+
|
| 94 |
+
The final event has done=True and a `result` dict with the report, sources and fact-check metrics.
|
| 95 |
+
`searcher`, `embedder` and `llm_api` can be replaced for testing.
|
| 96 |
+
"""
|
| 97 |
+
started = time.time()
|
| 98 |
+
searcher = searcher or search_topic
|
| 99 |
+
llm_api = llm_api or DEFAULT_LLM
|
| 100 |
+
cfg = config.DEPTH_PRESETS.get(depth, config.DEPTH_PRESETS[config.DEFAULT_DEPTH])
|
| 101 |
+
trace = Trace()
|
| 102 |
+
|
| 103 |
+
topic = (topic or "").strip()
|
| 104 |
+
if len(topic) < 2:
|
| 105 |
+
yield _event(trace, report="### ⚠️ Please enter a research topic.", error=True)
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
# ---- 0. uploaded documents ---------------------------------------------------------
|
| 109 |
+
documents = []
|
| 110 |
+
if document_paths:
|
| 111 |
+
step = trace.start("Documents", "reading uploaded files...")
|
| 112 |
+
yield _event(trace)
|
| 113 |
+
documents, warnings = load_documents(document_paths)
|
| 114 |
+
trace.finish(
|
| 115 |
+
step,
|
| 116 |
+
f"{len(documents)} file(s) loaded" if documents else "no readable documents",
|
| 117 |
+
"ok" if documents else "warn",
|
| 118 |
+
items=warnings,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
if not use_web and not documents:
|
| 122 |
+
yield _event(trace, report="### ⚠️ Web search is off and no readable documents were uploaded, so there is nothing to research.", error=True)
|
| 123 |
+
return
|
| 124 |
+
|
| 125 |
+
# ---- 1. Planner --------------------------------------------------------------------
|
| 126 |
+
step = trace.start("Planner", "splitting the topic into research questions...")
|
| 127 |
+
yield _event(trace)
|
| 128 |
+
try:
|
| 129 |
+
questions = agents.plan(topic, cfg["sub_questions"], llm_api.complete)
|
| 130 |
+
trace.finish(step, f"{len(questions)} research question(s)", items=[f"{i}. {q}" for i, q in enumerate(questions, 1)])
|
| 131 |
+
except llm.LLMError as e:
|
| 132 |
+
questions = [topic]
|
| 133 |
+
trace.finish(step, f"planner unavailable ({e}); using the topic as a single question", "warn")
|
| 134 |
+
yield _event(trace)
|
| 135 |
+
|
| 136 |
+
# ---- 2. Researcher -----------------------------------------------------------------
|
| 137 |
+
sources, errors = list(documents), []
|
| 138 |
+
if use_web:
|
| 139 |
+
step = trace.start("Researcher", f"searching the web ({len(questions)} parallel searches)...")
|
| 140 |
+
yield _event(trace)
|
| 141 |
+
web_sources, errors = agents.research_web(questions, cfg, searcher)
|
| 142 |
+
sources = web_sources + sources
|
| 143 |
+
if web_sources:
|
| 144 |
+
trace.finish(step, f"{len(web_sources)} web source(s) found", items=[f"note: {e}" for e in errors])
|
| 145 |
+
else:
|
| 146 |
+
trace.finish(step, "no web results", "warn", items=errors)
|
| 147 |
+
yield _event(trace)
|
| 148 |
+
|
| 149 |
+
if not sources:
|
| 150 |
+
message = "### ⚠️ " + (errors[0] if use_web and errors else "No sources were found for this topic.")
|
| 151 |
+
yield _event(trace, report=message, error=True)
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
# ---- 3. Index + retrieve -----------------------------------------------------------
|
| 155 |
+
step = trace.start("Retriever", "chunking, embedding and indexing sources...")
|
| 156 |
+
yield _event(trace)
|
| 157 |
+
try:
|
| 158 |
+
store = EvidenceStore(sources, embedder)
|
| 159 |
+
selected, numbered = store.gather(questions, cfg["chunks_per_question"])
|
| 160 |
+
except EvidenceError as e:
|
| 161 |
+
trace.finish(step, str(e), "fail")
|
| 162 |
+
yield _event(trace, report=f"### ⚠️ {e}", error=True)
|
| 163 |
+
return
|
| 164 |
+
except Exception as e:
|
| 165 |
+
print(f"[DEBUG ERROR] Retrieval failed: {e}")
|
| 166 |
+
trace.finish(step, f"retrieval failed: {e}", "fail")
|
| 167 |
+
yield _event(trace, report=f"### ❌ Retrieval failed: `{e}`", error=True)
|
| 168 |
+
return
|
| 169 |
+
trace.finish(step, f"{len(store.records)} passages indexed, {len(selected)} selected from {len(numbered)} source(s)")
|
| 170 |
+
yield _event(trace)
|
| 171 |
+
|
| 172 |
+
# ---- 4. Writer ---------------------------------------------------------------------
|
| 173 |
+
step = trace.start("Writer", "drafting the cited report...")
|
| 174 |
+
draft = ""
|
| 175 |
+
try:
|
| 176 |
+
for delta in agents.write_stream(topic, mode, questions, selected, numbered, llm_api.stream):
|
| 177 |
+
draft += delta
|
| 178 |
+
shown = agents.clean_citations(draft, len(numbered))
|
| 179 |
+
yield _event(trace, report="*Draft — fact-checking will follow...*\n\n" + shown)
|
| 180 |
+
except llm.LLMError as e:
|
| 181 |
+
trace.finish(step, str(e), "fail")
|
| 182 |
+
yield _event(trace, report=f"### ❌ {e}", error=True)
|
| 183 |
+
return
|
| 184 |
+
report = agents.clean_citations(draft, len(numbered)).strip()
|
| 185 |
+
if not report:
|
| 186 |
+
trace.finish(step, "empty answer", "fail")
|
| 187 |
+
yield _event(trace, report="### ⚠️ The model returned an empty answer. Please try again.", error=True)
|
| 188 |
+
return
|
| 189 |
+
trace.finish(step, f"draft written ({len(report.split())} words)")
|
| 190 |
+
yield _event(trace, report="*Draft — fact-checking in progress...*\n\n" + report)
|
| 191 |
+
|
| 192 |
+
# ---- 5. Checker (+ Reviser loop) ---------------------------------------------------
|
| 193 |
+
first_metrics = final_metrics = None
|
| 194 |
+
revised_flag = False
|
| 195 |
+
step = trace.start("Checker", "verifying every cited claim against its sources...")
|
| 196 |
+
yield _event(trace, report="*Draft — fact-checking in progress...*\n\n" + report)
|
| 197 |
+
try:
|
| 198 |
+
claims, results, first_metrics = _check(report, store, llm_api.complete)
|
| 199 |
+
final_metrics = first_metrics
|
| 200 |
+
if first_metrics["checked"]:
|
| 201 |
+
m = first_metrics
|
| 202 |
+
trace.finish(step, f"{m['supported']}/{m['checked']} claims supported, {m['partial']} partial, {m['unsupported']} unsupported")
|
| 203 |
+
else:
|
| 204 |
+
trace.finish(step, "no claims could be verified", "warn")
|
| 205 |
+
yield _event(trace, report="*Draft — fact-checking in progress...*\n\n" + report)
|
| 206 |
+
|
| 207 |
+
for _ in range(config.MAX_REVISION_ROUNDS):
|
| 208 |
+
flagged = final_metrics["partial"] + final_metrics["unsupported"]
|
| 209 |
+
if not flagged:
|
| 210 |
+
break
|
| 211 |
+
step = trace.start("Reviser", f"rewriting {flagged} flagged claim(s)...")
|
| 212 |
+
yield _event(trace, report="*Draft — revising flagged claims...*\n\n" + report)
|
| 213 |
+
candidate = agents.revise(report, claims, results, store, llm_api.complete)
|
| 214 |
+
if candidate == report:
|
| 215 |
+
trace.finish(step, "no usable revision", "warn")
|
| 216 |
+
break
|
| 217 |
+
claims2, results2, metrics2 = _check(candidate, store, llm_api.complete)
|
| 218 |
+
old_rate = final_metrics["support_rate"] or 0
|
| 219 |
+
new_rate = metrics2["support_rate"] or 0
|
| 220 |
+
if metrics2["checked"] and new_rate >= old_rate:
|
| 221 |
+
report, claims, results, final_metrics, revised_flag = candidate, claims2, results2, metrics2, True
|
| 222 |
+
trace.finish(step, f"revised; now {metrics2['supported']}/{metrics2['checked']} claims supported")
|
| 223 |
+
else:
|
| 224 |
+
trace.finish(step, "revision did not improve the report; kept the original draft", "warn")
|
| 225 |
+
yield _event(trace, report=report)
|
| 226 |
+
except llm.LLMError as e:
|
| 227 |
+
trace.finish(step, f"unavailable ({e}); report delivered unverified", "warn")
|
| 228 |
+
yield _event(trace, report=report)
|
| 229 |
+
|
| 230 |
+
# ---- 6. Translator -----------------------------------------------------------------
|
| 231 |
+
english_report = report
|
| 232 |
+
if language != "English":
|
| 233 |
+
step = trace.start("Translator", f"translating the verified report to {language}...")
|
| 234 |
+
yield _event(trace, report=report)
|
| 235 |
+
try:
|
| 236 |
+
report = agents.translate(report, language, llm_api.complete)
|
| 237 |
+
trace.finish(step, f"translated to {language}")
|
| 238 |
+
except llm.LLMError as e:
|
| 239 |
+
trace.finish(step, f"translation failed ({e}); showing English", "warn")
|
| 240 |
+
|
| 241 |
+
# ---- 7. Assemble -------------------------------------------------------------------
|
| 242 |
+
final_md = report + sources_markdown(numbered) + verification_markdown(final_metrics, first_metrics, revised_flag)
|
| 243 |
+
elapsed = round(time.time() - started, 1)
|
| 244 |
+
result = {
|
| 245 |
+
"topic": topic,
|
| 246 |
+
"report": final_md,
|
| 247 |
+
"report_english": english_report,
|
| 248 |
+
"language": language,
|
| 249 |
+
"questions": questions,
|
| 250 |
+
"sources": numbered,
|
| 251 |
+
"metrics": {
|
| 252 |
+
"first_pass": first_metrics,
|
| 253 |
+
"final": final_metrics,
|
| 254 |
+
"revised": revised_flag,
|
| 255 |
+
"sources": len(numbered),
|
| 256 |
+
"passages_indexed": len(store.records),
|
| 257 |
+
"seconds": elapsed,
|
| 258 |
+
},
|
| 259 |
+
}
|
| 260 |
+
yield _event(trace, report=final_md, done=True, result=result)
|
Researchmind-AI/src/prompts.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---------------------------------------------------------------- Planner
|
| 2 |
+
SYSTEM_PLANNER = (
|
| 3 |
+
"You are the Planner of a research agent. You break a topic into focused research sub-questions "
|
| 4 |
+
"and reply with JSON only."
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_planner_prompt(topic: str, n: int) -> str:
|
| 9 |
+
return (
|
| 10 |
+
f"Topic: {topic}\n\n"
|
| 11 |
+
f"Break this topic into exactly {n} specific, non-overlapping research sub-questions that together cover it "
|
| 12 |
+
"(for example: background, current state, evidence or data, challenges or risks, outlook). "
|
| 13 |
+
"Each sub-question must be a self-contained web-search query written in English, at most 15 words.\n\n"
|
| 14 |
+
'Return ONLY JSON in this form: {"sub_questions": ["...", "..."]}'
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ----------------------------------------------------------------- Writer
|
| 19 |
+
SYSTEM_WRITER = (
|
| 20 |
+
"You are ResearchMind, a careful research writer. "
|
| 21 |
+
"You write reports using ONLY the numbered sources provided by the user. "
|
| 22 |
+
"Every factual claim must end with a citation such as [1] or [2][3]. "
|
| 23 |
+
"If the sources do not cover something, say so explicitly instead of filling the gap from memory. "
|
| 24 |
+
"Never invent sources, URLs, statistics or quotes. "
|
| 25 |
+
"Do not write a references list; it is added automatically."
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
MODE_INSTRUCTIONS = {
|
| 29 |
+
"Standard": (
|
| 30 |
+
"Write a clear, structured report with markdown sections such as Introduction, Key Findings, "
|
| 31 |
+
"Current Developments, Challenges, Outlook and Conclusion. Use short paragraphs and bullet points where helpful."
|
| 32 |
+
),
|
| 33 |
+
"Summary": (
|
| 34 |
+
"Write a concise summary: a 2-3 sentence overview followed by 5-7 bullet points "
|
| 35 |
+
"covering the most important findings."
|
| 36 |
+
),
|
| 37 |
+
"Academic": (
|
| 38 |
+
"Write in a formal academic tone with sections such as Abstract, Introduction, Background, Analysis, "
|
| 39 |
+
"Discussion, Limitations and Conclusion. Be precise, avoid hype, and note where sources disagree or are limited."
|
| 40 |
+
),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_writer_prompt(topic: str, mode: str, questions: list, context: str, num_sources: int) -> str:
|
| 45 |
+
outline = "\n".join(f"{i}. {q}" for i, q in enumerate(questions, 1))
|
| 46 |
+
return (
|
| 47 |
+
f"Topic: {topic}\n\n"
|
| 48 |
+
f"Research questions to cover:\n{outline}\n\n"
|
| 49 |
+
f"Sources:\n{context}\n\n"
|
| 50 |
+
f"Task: {MODE_INSTRUCTIONS.get(mode, MODE_INSTRUCTIONS['Standard'])} "
|
| 51 |
+
"Make sure the report addresses each research question.\n\n"
|
| 52 |
+
f"Rules: cite only with the numbers 1 to {num_sources}, placed right after the claim they support. "
|
| 53 |
+
"Use only information found in the sources above."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------- Checker
|
| 58 |
+
SYSTEM_CHECKER = (
|
| 59 |
+
"You are a strict fact-checker. You judge whether a claim is supported by the evidence quoted from "
|
| 60 |
+
"its cited sources. Use only the evidence given; never use outside knowledge. Reply with JSON only."
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_check_prompt(items: list) -> str:
|
| 65 |
+
blocks = []
|
| 66 |
+
for item in items:
|
| 67 |
+
evidence = "\n".join(f"- {e}" for e in item["evidence"]) or "- (no evidence available)"
|
| 68 |
+
blocks.append(f"CLAIM {item['id']}: {item['text']}\nEVIDENCE {item['id']}:\n{evidence}")
|
| 69 |
+
return (
|
| 70 |
+
"Judge each claim against its evidence.\n"
|
| 71 |
+
'Verdicts: "supported" (the evidence clearly states or directly implies the claim), '
|
| 72 |
+
'"partial" (the evidence supports only part of the claim, or is vaguer than the claim), '
|
| 73 |
+
'"unsupported" (the evidence does not contain the claim or contradicts it).\n\n'
|
| 74 |
+
'Return ONLY JSON: {"results": [{"id": 1, "verdict": "supported", "reason": "max 15 words"}]}\n\n'
|
| 75 |
+
+ "\n\n".join(blocks)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------- Reviser
|
| 80 |
+
SYSTEM_REVISER = (
|
| 81 |
+
"You are the Reviser of a research agent. You fix a report so that every claim is strictly backed "
|
| 82 |
+
"by the evidence provided."
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def build_revise_prompt(report: str, issues: list) -> str:
|
| 87 |
+
blocks = []
|
| 88 |
+
for i, issue in enumerate(issues, 1):
|
| 89 |
+
evidence = "\n".join(f" - {e}" for e in issue["evidence"]) or " - (none)"
|
| 90 |
+
blocks.append(
|
| 91 |
+
f"{i}. Claim: {issue['text']}\n Fact-check verdict: {issue['verdict']} ({issue['reason']})\n"
|
| 92 |
+
f" Evidence from the cited source:\n{evidence}"
|
| 93 |
+
)
|
| 94 |
+
return (
|
| 95 |
+
f"REPORT:\n{report}\n\n"
|
| 96 |
+
"A fact-checker flagged these claims:\n\n" + "\n\n".join(blocks) + "\n\n"
|
| 97 |
+
"For each flagged claim either (a) delete it, or (b) rewrite it so it states ONLY what the evidence supports. "
|
| 98 |
+
"Do not add new facts or new sources. Keep the headings, structure and [n] citation markers of everything else. "
|
| 99 |
+
"Return the complete corrected report and nothing else."
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ------------------------------------------------------------- Translator
|
| 104 |
+
SYSTEM_TRANSLATOR = "You are a professional translator who preserves formatting exactly."
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_translate_prompt(report: str, language: str) -> str:
|
| 108 |
+
return (
|
| 109 |
+
f"Translate the following research report into {language}. "
|
| 110 |
+
"Keep the markdown structure (headings, bullets). Keep citation markers such as [1] exactly as they are. "
|
| 111 |
+
"Do not translate URLs. Return only the translation.\n\n" + report
|
| 112 |
+
)
|
Researchmind-AI/src/search.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class SearchError(Exception):
|
| 9 |
+
"""Raised when web search cannot run or returns nothing usable."""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _get_key() -> str:
|
| 13 |
+
return os.getenv("TAVILY_API_KEY", "").strip().strip('"').strip("'")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def search_topic(topic: str, max_results: int = 6, search_depth: str = "advanced") -> list:
|
| 17 |
+
"""Searches the web with Tavily and returns [{title, content, url}, ...].
|
| 18 |
+
|
| 19 |
+
Raises SearchError with a user-friendly message if the key is missing,
|
| 20 |
+
the request fails, or no usable results come back.
|
| 21 |
+
"""
|
| 22 |
+
key = _get_key()
|
| 23 |
+
if not key:
|
| 24 |
+
raise SearchError("`TAVILY_API_KEY` is missing in your `.env` file (or Space secrets).")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from tavily import TavilyClient
|
| 28 |
+
|
| 29 |
+
client = TavilyClient(api_key=key)
|
| 30 |
+
response = client.search(
|
| 31 |
+
query=topic,
|
| 32 |
+
search_depth=search_depth,
|
| 33 |
+
max_results=max_results,
|
| 34 |
+
)
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"[DEBUG ERROR] Tavily search failed for '{topic}': {e}")
|
| 37 |
+
raise SearchError(f"Web search failed: `{e}`")
|
| 38 |
+
|
| 39 |
+
results, seen_urls = [], set()
|
| 40 |
+
for item in response.get("results", []):
|
| 41 |
+
url = item.get("url", "")
|
| 42 |
+
content = (item.get("content") or "").strip()
|
| 43 |
+
if not content or url in seen_urls:
|
| 44 |
+
continue
|
| 45 |
+
seen_urls.add(url)
|
| 46 |
+
results.append({
|
| 47 |
+
"title": (item.get("title") or url or "Untitled source").strip(),
|
| 48 |
+
"content": content,
|
| 49 |
+
"url": url,
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
if not results:
|
| 53 |
+
raise SearchError("The web search returned no usable results for this topic.")
|
| 54 |
+
return results
|
Researchmind-AI/tests/__init__.py
ADDED
|
File without changes
|
Researchmind-AI/tests/conftest.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
import types
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class FakeEmbedder:
|
| 11 |
+
"""Deterministic bag-of-words hashing embedder, so similarity reflects shared words (no model download)."""
|
| 12 |
+
|
| 13 |
+
def encode(self, texts, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False):
|
| 14 |
+
out = np.zeros((len(texts), 256), dtype="float32")
|
| 15 |
+
for i, text in enumerate(texts):
|
| 16 |
+
for word in re.findall(r"[a-z]+", text.lower()):
|
| 17 |
+
word = re.sub(r"(ing|s)$", "", word) # crude stemming: drone/drones, monitor/monitoring
|
| 18 |
+
out[i, int(hashlib.md5(word.encode()).hexdigest(), 16) % 256] += 1
|
| 19 |
+
if normalize_embeddings:
|
| 20 |
+
out /= np.maximum(np.linalg.norm(out, axis=1, keepdims=True), 1e-9)
|
| 21 |
+
return out
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
WEB = {
|
| 25 |
+
"solar": [
|
| 26 |
+
{"title": "Agrivoltaics explained", "url": "https://a.example/agri",
|
| 27 |
+
"content": "Agrivoltaics combines solar panels with crops on the same farmland. It improves land use and farm income. " * 10},
|
| 28 |
+
],
|
| 29 |
+
"drone": [
|
| 30 |
+
{"title": "Farm drones", "url": "https://b.example/drones",
|
| 31 |
+
"content": "Drones monitor crops on farmland and help farmers detect plant disease early using imaging. " * 10},
|
| 32 |
+
{"title": "Cooking pasta", "url": "https://c.example/pasta",
|
| 33 |
+
"content": "Boil water, add salt, cook pasta for ten minutes and serve with sauce. " * 8},
|
| 34 |
+
],
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def fake_searcher(question, max_results=4, search_depth="basic"):
|
| 39 |
+
results = []
|
| 40 |
+
for key, items in WEB.items():
|
| 41 |
+
if key in question.lower():
|
| 42 |
+
results += items
|
| 43 |
+
return results or WEB["solar"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class FakeLLM:
|
| 47 |
+
"""Scripted stand-in for Groq. Role is detected from the system prompt."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, bad_claim="The moon is made of cheese"):
|
| 50 |
+
self.bad_claim = bad_claim
|
| 51 |
+
self.calls = []
|
| 52 |
+
|
| 53 |
+
def complete(self, messages, role="fast", temperature=0.0, json_mode=False):
|
| 54 |
+
system, user = messages[0]["content"], messages[-1]["content"]
|
| 55 |
+
self.calls.append(system.split(".")[0])
|
| 56 |
+
if "Planner" in system:
|
| 57 |
+
return json.dumps({"sub_questions": ["solar panels on farmland", "drone crop monitoring"]})
|
| 58 |
+
if "fact-checker" in system:
|
| 59 |
+
results = []
|
| 60 |
+
for match in re.finditer(r"CLAIM (\d+): (.*)", user):
|
| 61 |
+
verdict = "unsupported" if self.bad_claim.lower() in match.group(2).lower() else "supported"
|
| 62 |
+
results.append({"id": int(match.group(1)), "verdict": verdict, "reason": "scripted"})
|
| 63 |
+
return json.dumps({"results": results})
|
| 64 |
+
if "Reviser" in system:
|
| 65 |
+
report = user.split("A fact-checker flagged")[0].replace("REPORT:\n", "")
|
| 66 |
+
return "\n".join(l for l in report.split("\n") if self.bad_claim.lower() not in l.lower())
|
| 67 |
+
if "translator" in system:
|
| 68 |
+
return "[UR] " + user.split("\n\n", 1)[1]
|
| 69 |
+
raise AssertionError("unexpected prompt: " + system)
|
| 70 |
+
|
| 71 |
+
def stream(self, messages, role="writer", temperature=0.3):
|
| 72 |
+
self.calls.append("writer")
|
| 73 |
+
text = (
|
| 74 |
+
"## Key Findings\n"
|
| 75 |
+
"Solar panels and crops can share the same farmland [1]. "
|
| 76 |
+
"Drones help farmers detect plant disease early [2][9].\n"
|
| 77 |
+
f"{self.bad_claim} [1].\n"
|
| 78 |
+
"## Conclusion\n"
|
| 79 |
+
"Both technologies improve how farmland is used [1, 2].\n"
|
| 80 |
+
)
|
| 81 |
+
for i in range(0, len(text), 25):
|
| 82 |
+
yield text[i : i + 25]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.fixture
|
| 86 |
+
def embedder():
|
| 87 |
+
return FakeEmbedder()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@pytest.fixture
|
| 91 |
+
def fake_llm():
|
| 92 |
+
llm = FakeLLM()
|
| 93 |
+
return types.SimpleNamespace(complete=llm.complete, stream=llm.stream, raw=llm)
|
Researchmind-AI/tests/test_app.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import functools
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
pytest.importorskip("gradio")
|
| 7 |
+
|
| 8 |
+
import app
|
| 9 |
+
from src import pipeline
|
| 10 |
+
from tests.conftest import fake_searcher
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def patched_app(monkeypatch, fake_llm, embedder):
|
| 15 |
+
"""Runs the real UI handler with fake search/LLM/embeddings injected."""
|
| 16 |
+
monkeypatch.setattr(
|
| 17 |
+
app,
|
| 18 |
+
"run_research",
|
| 19 |
+
functools.partial(pipeline.run_research, searcher=fake_searcher, embedder=embedder, llm_api=fake_llm),
|
| 20 |
+
)
|
| 21 |
+
return app
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def collect(gen):
|
| 25 |
+
return list(gen)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_ui_builds_with_expected_controls():
|
| 29 |
+
assert app.app is not None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_run_agent_streams_then_offers_md_and_pdf(patched_app):
|
| 33 |
+
outputs = collect(patched_app.run_agent("AI in agriculture", "Standard", "English", "Standard", True, None))
|
| 34 |
+
welcome, trace, report, files = outputs[-1]
|
| 35 |
+
assert "Agent activity" in trace["value"] and "Verification" in report["value"]
|
| 36 |
+
paths = files["value"]
|
| 37 |
+
assert files["visible"] and {os.path.splitext(p)[1] for p in paths} == {".md", ".pdf"}
|
| 38 |
+
assert all(os.path.getsize(p) > 100 for p in paths)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_urdu_gets_markdown_only_and_rtl(patched_app):
|
| 42 |
+
outputs = collect(patched_app.run_agent("AI in agriculture", "Standard", "Urdu", "Standard", True, None))
|
| 43 |
+
_, _, report, files = outputs[-1]
|
| 44 |
+
assert report.get("rtl") is True
|
| 45 |
+
assert [os.path.splitext(p)[1] for p in files["value"]] == [".md"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_empty_query_keeps_welcome_screen(patched_app):
|
| 49 |
+
welcome, trace, report, files = collect(patched_app.run_agent("", "Standard", "English", "Standard", True, None))[0]
|
| 50 |
+
assert welcome["visible"] is True and report["visible"] is False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_error_event_shows_message_and_no_files(patched_app):
|
| 54 |
+
outputs = collect(patched_app.run_agent("topic here", "Standard", "English", "Standard", False, None))
|
| 55 |
+
_, _, report, files = outputs[-1]
|
| 56 |
+
assert "nothing to research" in report["value"] and files["visible"] is False
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_pdf_handles_emoji_links_and_headings(tmp_path):
|
| 60 |
+
path = app.create_pdf("# Title\n\n## Sources\n\n1. [Solar & crops](https://x.example/a?b=1&c=2)\n2. 📄 **notes.pdf** (uploaded)\n\n> 🔎 **Verification:** ok")
|
| 61 |
+
assert path and os.path.getsize(path) > 500
|
Researchmind-AI/tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from src import agents, config, pipeline
|
| 6 |
+
from src.documents import load_documents
|
| 7 |
+
from src.evidence import EvidenceStore
|
| 8 |
+
from tests.conftest import WEB, fake_searcher
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def run(fake_llm, embedder, **kwargs):
|
| 12 |
+
events = list(pipeline.run_research(
|
| 13 |
+
"AI in agriculture", searcher=fake_searcher, embedder=embedder, llm_api=fake_llm, **kwargs
|
| 14 |
+
))
|
| 15 |
+
return events, events[-1]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ------------------------------------------------------------ agents
|
| 19 |
+
def test_parse_json_handles_fences_and_prose():
|
| 20 |
+
assert agents.parse_json('```json\n{"a": 1}\n```') == {"a": 1}
|
| 21 |
+
assert agents.parse_json('Sure! {"a": 1} hope that helps') == {"a": 1}
|
| 22 |
+
assert agents.parse_json("not json") is None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_planner_falls_back_to_topic_on_garbage(fake_llm):
|
| 26 |
+
assert agents.plan("my topic", 3, lambda *a, **k: "garbage") == ["my topic"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_planner_limits_and_dedupes():
|
| 30 |
+
reply = '{"sub_questions": ["a", "a", "b", "c", "d"]}'
|
| 31 |
+
assert agents.plan("t", 3, lambda *a, **k: reply) == ["a", "b", "c"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_clean_citations():
|
| 35 |
+
assert agents.clean_citations("A [1] B [9] C [2, 7] D [2026]", 3) == "A [1] B C [2] D [2026]"
|
| 36 |
+
assert agents.clean_citations("x [1, 2]", 5) == "x [1][2]"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_extract_claims_keeps_citation_with_its_sentence():
|
| 40 |
+
report = (
|
| 41 |
+
"## Heading\n"
|
| 42 |
+
"- Solar panels share land with crops. [1] Drones detect disease early [2].\n"
|
| 43 |
+
"This sentence has plenty of words but no citation at all in it.\n"
|
| 44 |
+
"> quoted verification note [1]\n"
|
| 45 |
+
)
|
| 46 |
+
claims, uncited = agents.extract_claims(report)
|
| 47 |
+
assert [c["cites"] for c in claims] == [[1], [2]]
|
| 48 |
+
assert claims[0]["text"].startswith("Solar panels share land with crops")
|
| 49 |
+
assert uncited == 1
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_research_web_dedupes_urls_and_collects_errors():
|
| 53 |
+
from src.search import SearchError
|
| 54 |
+
|
| 55 |
+
def searcher(q, max_results, search_depth):
|
| 56 |
+
if q == "bad":
|
| 57 |
+
raise SearchError("boom")
|
| 58 |
+
return [{"title": "T", "url": "https://x", "content": "text"}]
|
| 59 |
+
|
| 60 |
+
sources, errors = agents.research_web(["a", "b", "bad"], {"results_per_question": 2, "search_depth": "basic"}, searcher)
|
| 61 |
+
assert len(sources) == 1 and errors == ["boom"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------- evidence
|
| 65 |
+
def test_evidence_ranks_relevant_sources_first_and_drops_irrelevant(embedder):
|
| 66 |
+
sources = [dict(s, kind="web") for s in WEB["solar"] + WEB["drone"]]
|
| 67 |
+
store = EvidenceStore(sources, embedder)
|
| 68 |
+
selected, numbered = store.gather(["solar panels on farmland", "drone crop monitoring"], 5)
|
| 69 |
+
titles = [s["title"] for s in numbered]
|
| 70 |
+
assert titles[0] == "Agrivoltaics explained"
|
| 71 |
+
assert "Cooking pasta" not in titles
|
| 72 |
+
assert len(selected) <= config.MAX_EVIDENCE_CHUNKS
|
| 73 |
+
assert all(sum(1 for c in selected if c["source_idx"] == i) <= config.MAX_CHUNKS_PER_SOURCE for i in range(3))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_support_for_returns_passages_from_cited_source_only(embedder):
|
| 77 |
+
sources = [dict(s, kind="web") for s in WEB["solar"] + WEB["drone"]]
|
| 78 |
+
store = EvidenceStore(sources, embedder)
|
| 79 |
+
store.gather(["solar panels on farmland", "drone crop monitoring"], 5)
|
| 80 |
+
passages = store.support_for("drones detect plant disease", [2], k=2)
|
| 81 |
+
assert passages and all("Drones" in p for p in passages)
|
| 82 |
+
assert store.support_for("anything", [99]) == []
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ----------------------------------------------------------- pipeline
|
| 86 |
+
def test_full_agent_loop_catches_and_fixes_unsupported_claim(fake_llm, embedder):
|
| 87 |
+
events, last = run(fake_llm, embedder)
|
| 88 |
+
assert last["done"] and not last["error"]
|
| 89 |
+
result = last["result"]
|
| 90 |
+
|
| 91 |
+
# Checker flagged the planted claim, Reviser removed it, re-check is clean
|
| 92 |
+
m = result["metrics"]
|
| 93 |
+
assert m["first_pass"]["unsupported"] == 1
|
| 94 |
+
assert m["final"]["unsupported"] == 0 and m["final"]["support_rate"] == 1.0
|
| 95 |
+
assert m["revised"] is True
|
| 96 |
+
assert "cheese" not in result["report"]
|
| 97 |
+
|
| 98 |
+
# Invented citation [9] removed, sources + verification note appended
|
| 99 |
+
assert "[9]" not in result["report"]
|
| 100 |
+
assert "## Sources" in result["report"] and "https://a.example/agri" in result["report"]
|
| 101 |
+
assert "Verification" in result["report"]
|
| 102 |
+
|
| 103 |
+
# Trace shows every agent
|
| 104 |
+
trace = last["trace"]
|
| 105 |
+
for agent in ("Planner", "Researcher", "Retriever", "Writer", "Checker", "Reviser"):
|
| 106 |
+
assert agent in trace
|
| 107 |
+
assert "Translator" not in trace
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_draft_streams_progressively(fake_llm, embedder):
|
| 111 |
+
events, _ = run(fake_llm, embedder)
|
| 112 |
+
drafts = [e["report"] for e in events if e["report"] and e["report"].startswith("*Draft")]
|
| 113 |
+
assert len(drafts) >= 3
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_urdu_runs_translator_after_verification(fake_llm, embedder):
|
| 117 |
+
_, last = run(fake_llm, embedder, language="Urdu")
|
| 118 |
+
result = last["result"]
|
| 119 |
+
assert result["report"].startswith("[UR]")
|
| 120 |
+
assert "cheese" not in result["report_english"]
|
| 121 |
+
assert "Translator" in last["trace"]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_documents_only_mode(fake_llm, embedder, tmp_path):
|
| 125 |
+
doc = tmp_path / "notes.txt"
|
| 126 |
+
doc.write_text("Greenhouse farming with solar panels on farmland increases crop yield per hectare. " * 10)
|
| 127 |
+
_, last = run(fake_llm, embedder, use_web=False, document_paths=[str(doc)])
|
| 128 |
+
assert last["done"]
|
| 129 |
+
sources = last["result"]["sources"]
|
| 130 |
+
assert sources and all(s["kind"] == "document" for s in sources)
|
| 131 |
+
assert "📄" in last["result"]["report"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_no_web_and_no_documents_is_a_clear_error(fake_llm, embedder):
|
| 135 |
+
_, last = run(fake_llm, embedder, use_web=False)
|
| 136 |
+
assert last["error"] and "nothing to research" in last["report"]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_empty_topic_is_rejected(fake_llm, embedder):
|
| 140 |
+
events = list(pipeline.run_research(" ", searcher=fake_searcher, embedder=embedder, llm_api=fake_llm))
|
| 141 |
+
assert events[-1]["error"]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_search_failure_without_documents_surfaces_message(fake_llm, embedder):
|
| 145 |
+
from src.search import SearchError
|
| 146 |
+
|
| 147 |
+
def broken(q, max_results=4, search_depth="basic"):
|
| 148 |
+
raise SearchError("`TAVILY_API_KEY` is missing")
|
| 149 |
+
|
| 150 |
+
events = list(pipeline.run_research("topic here", searcher=broken, embedder=embedder, llm_api=fake_llm))
|
| 151 |
+
assert events[-1]["error"] and "TAVILY_API_KEY" in events[-1]["report"]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_writer_failure_is_reported_not_raised(embedder, fake_llm):
|
| 155 |
+
import types
|
| 156 |
+
from src.llm import LLMError
|
| 157 |
+
|
| 158 |
+
def bad_stream(*a, **k):
|
| 159 |
+
raise LLMError("All Groq models failed.")
|
| 160 |
+
yield
|
| 161 |
+
|
| 162 |
+
broken = types.SimpleNamespace(complete=fake_llm.complete, stream=bad_stream)
|
| 163 |
+
events = list(pipeline.run_research("topic here", searcher=fake_searcher, embedder=embedder, llm_api=broken))
|
| 164 |
+
assert events[-1]["error"] and "All Groq models failed" in events[-1]["report"]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_checker_outage_still_delivers_report(embedder, fake_llm):
|
| 168 |
+
import types
|
| 169 |
+
from src.llm import LLMError
|
| 170 |
+
|
| 171 |
+
def complete(messages, role="fast", temperature=0.0, json_mode=False):
|
| 172 |
+
if "Planner" in messages[0]["content"]:
|
| 173 |
+
return fake_llm.complete(messages, role, temperature, json_mode)
|
| 174 |
+
raise LLMError("rate limited")
|
| 175 |
+
|
| 176 |
+
llm_api = types.SimpleNamespace(complete=complete, stream=fake_llm.stream)
|
| 177 |
+
_, last = None, list(pipeline.run_research("topic here", searcher=fake_searcher, embedder=embedder, llm_api=llm_api))[-1]
|
| 178 |
+
assert last["done"] and "unavailable" in last["result"]["report"]
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_reviser_guard_rejects_stub_rewrites(embedder):
|
| 182 |
+
report = "word " * 200
|
| 183 |
+
claims = [{"id": 1, "text": "x [1]", "cites": [1]}]
|
| 184 |
+
results = {1: {"verdict": "unsupported", "reason": "r"}}
|
| 185 |
+
|
| 186 |
+
class Store:
|
| 187 |
+
def support_for(self, *a, **k):
|
| 188 |
+
return ["evidence"]
|
| 189 |
+
|
| 190 |
+
assert agents.revise(report, claims, results, Store(), lambda *a, **k: "ok") == report
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------------------------------------------------- documents
|
| 194 |
+
def test_load_documents_pdf_txt_and_warnings(tmp_path):
|
| 195 |
+
from reportlab.lib.pagesizes import LETTER
|
| 196 |
+
from reportlab.platypus import Paragraph, SimpleDocTemplate
|
| 197 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 198 |
+
|
| 199 |
+
pdf = tmp_path / "paper.pdf"
|
| 200 |
+
SimpleDocTemplate(str(pdf), pagesize=LETTER).build(
|
| 201 |
+
[Paragraph("Agrivoltaics lets farmers grow crops under solar panels and earn extra income. " * 5, getSampleStyleSheet()["BodyText"])]
|
| 202 |
+
)
|
| 203 |
+
txt = tmp_path / "empty.txt"
|
| 204 |
+
txt.write_text("hi")
|
| 205 |
+
odd = tmp_path / "data.xyz"
|
| 206 |
+
odd.write_text("whatever " * 30)
|
| 207 |
+
|
| 208 |
+
docs, warnings = load_documents([str(pdf), str(txt), str(odd)])
|
| 209 |
+
assert [d["title"] for d in docs] == ["paper.pdf"]
|
| 210 |
+
assert "Agrivoltaics" in docs[0]["content"] and docs[0]["kind"] == "document"
|
| 211 |
+
assert len(warnings) == 2
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ---------------------------------------------------------- evaluation
|
| 215 |
+
def test_evaluate_aggregates_and_writes_reports(fake_llm, embedder, tmp_path):
|
| 216 |
+
import functools
|
| 217 |
+
|
| 218 |
+
import evaluate
|
| 219 |
+
|
| 220 |
+
runner = functools.partial(pipeline.run_research, searcher=fake_searcher, embedder=embedder, llm_api=fake_llm)
|
| 221 |
+
rows = [evaluate.evaluate_topic("AI in agriculture", "Quick", runner)]
|
| 222 |
+
rows.append(evaluate.evaluate_topic("x", "Quick", runner)) # too short -> failure row
|
| 223 |
+
assert rows[0]["ok"] and rows[0]["support_first"] < rows[0]["support_final"] == 1.0
|
| 224 |
+
assert not rows[1]["ok"]
|
| 225 |
+
|
| 226 |
+
summary = evaluate.aggregate(rows)
|
| 227 |
+
assert summary["completed"] == 1 and summary["revised_count"] == 1
|
| 228 |
+
evaluate.write_outputs(rows, summary, str(tmp_path), "Quick")
|
| 229 |
+
text = (tmp_path / "RESULTS.md").read_text(encoding="utf-8")
|
| 230 |
+
assert "first draft" in text and "100%" in text and (tmp_path / "results.json").exists()
|