MuhammadAzfar commited on
Commit
da4758c
·
0 Parent(s):

Initial clean deployment

Browse files
.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
+ ![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=for-the-badge&logo=python&logoColor=white)
6
+ ![Gradio](https://img.shields.io/badge/Gradio-UI-FF6F00?style=for-the-badge)
7
+ ![Groq](https://img.shields.io/badge/Groq-LLM-000000?style=for-the-badge)
8
+ ![RAG](https://img.shields.io/badge/RAG-Retrieval--Augmented--Generation-6A5ACD?style=for-the-badge)
9
+ ![Agentic](https://img.shields.io/badge/Agentic-Plan%20%E2%86%92%20Write%20%E2%86%92%20Verify-E91E63?style=for-the-badge)
10
+ ![FAISS](https://img.shields.io/badge/FAISS-Vector%20Search-009688?style=for-the-badge)
11
+ ![Tests](https://img.shields.io/badge/Tests-26%20passing-2E7D32?style=for-the-badge)
12
+ ![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)
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()