ResearchMind-AI / tests /test_pipeline.py
MuhammadAzfar's picture
Move project files to repository root
43da372
Raw History Blame Contribute Delete
9.52 kB
import os
import pytest
from src import agents, config, pipeline
from src.documents import load_documents
from src.evidence import EvidenceStore
from tests.conftest import WEB, fake_searcher
def run(fake_llm, embedder, **kwargs):
events = list(pipeline.run_research(
"AI in agriculture", searcher=fake_searcher, embedder=embedder, llm_api=fake_llm, **kwargs
))
return events, events[-1]
# ------------------------------------------------------------ agents
def test_parse_json_handles_fences_and_prose():
assert agents.parse_json('```json\n{"a": 1}\n```') == {"a": 1}
assert agents.parse_json('Sure! {"a": 1} hope that helps') == {"a": 1}
assert agents.parse_json("not json") is None
def test_planner_falls_back_to_topic_on_garbage(fake_llm):
assert agents.plan("my topic", 3, lambda *a, **k: "garbage") == ["my topic"]
def test_planner_limits_and_dedupes():
reply = '{"sub_questions": ["a", "a", "b", "c", "d"]}'
assert agents.plan("t", 3, lambda *a, **k: reply) == ["a", "b", "c"]
def test_clean_citations():
assert agents.clean_citations("A [1] B [9] C [2, 7] D [2026]", 3) == "A [1] B C [2] D [2026]"
assert agents.clean_citations("x [1, 2]", 5) == "x [1][2]"
def test_extract_claims_keeps_citation_with_its_sentence():
report = (
"## Heading\n"
"- Solar panels share land with crops. [1] Drones detect disease early [2].\n"
"This sentence has plenty of words but no citation at all in it.\n"
"> quoted verification note [1]\n"
)
claims, uncited = agents.extract_claims(report)
assert [c["cites"] for c in claims] == [[1], [2]]
assert claims[0]["text"].startswith("Solar panels share land with crops")
assert uncited == 1
def test_research_web_dedupes_urls_and_collects_errors():
from src.search import SearchError
def searcher(q, max_results, search_depth):
if q == "bad":
raise SearchError("boom")
return [{"title": "T", "url": "https://x", "content": "text"}]
sources, errors = agents.research_web(["a", "b", "bad"], {"results_per_question": 2, "search_depth": "basic"}, searcher)
assert len(sources) == 1 and errors == ["boom"]
# ---------------------------------------------------------- evidence
def test_evidence_ranks_relevant_sources_first_and_drops_irrelevant(embedder):
sources = [dict(s, kind="web") for s in WEB["solar"] + WEB["drone"]]
store = EvidenceStore(sources, embedder)
selected, numbered = store.gather(["solar panels on farmland", "drone crop monitoring"], 5)
titles = [s["title"] for s in numbered]
assert titles[0] == "Agrivoltaics explained"
assert "Cooking pasta" not in titles
assert len(selected) <= config.MAX_EVIDENCE_CHUNKS
assert all(sum(1 for c in selected if c["source_idx"] == i) <= config.MAX_CHUNKS_PER_SOURCE for i in range(3))
def test_support_for_returns_passages_from_cited_source_only(embedder):
sources = [dict(s, kind="web") for s in WEB["solar"] + WEB["drone"]]
store = EvidenceStore(sources, embedder)
store.gather(["solar panels on farmland", "drone crop monitoring"], 5)
passages = store.support_for("drones detect plant disease", [2], k=2)
assert passages and all("Drones" in p for p in passages)
assert store.support_for("anything", [99]) == []
# ----------------------------------------------------------- pipeline
def test_full_agent_loop_catches_and_fixes_unsupported_claim(fake_llm, embedder):
events, last = run(fake_llm, embedder)
assert last["done"] and not last["error"]
result = last["result"]
# Checker flagged the planted claim, Reviser removed it, re-check is clean
m = result["metrics"]
assert m["first_pass"]["unsupported"] == 1
assert m["final"]["unsupported"] == 0 and m["final"]["support_rate"] == 1.0
assert m["revised"] is True
assert "cheese" not in result["report"]
# Invented citation [9] removed, sources + verification note appended
assert "[9]" not in result["report"]
assert "## Sources" in result["report"] and "https://a.example/agri" in result["report"]
assert "Verification" in result["report"]
# Trace shows every agent
trace = last["trace"]
for agent in ("Planner", "Researcher", "Retriever", "Writer", "Checker", "Reviser"):
assert agent in trace
assert "Translator" not in trace
def test_draft_streams_progressively(fake_llm, embedder):
events, _ = run(fake_llm, embedder)
drafts = [e["report"] for e in events if e["report"] and e["report"].startswith("*Draft")]
assert len(drafts) >= 3
def test_urdu_runs_translator_after_verification(fake_llm, embedder):
_, last = run(fake_llm, embedder, language="Urdu")
result = last["result"]
assert result["report"].startswith("[UR]")
assert "cheese" not in result["report_english"]
assert "Translator" in last["trace"]
def test_documents_only_mode(fake_llm, embedder, tmp_path):
doc = tmp_path / "notes.txt"
doc.write_text("Greenhouse farming with solar panels on farmland increases crop yield per hectare. " * 10)
_, last = run(fake_llm, embedder, use_web=False, document_paths=[str(doc)])
assert last["done"]
sources = last["result"]["sources"]
assert sources and all(s["kind"] == "document" for s in sources)
assert "📄" in last["result"]["report"]
def test_no_web_and_no_documents_is_a_clear_error(fake_llm, embedder):
_, last = run(fake_llm, embedder, use_web=False)
assert last["error"] and "nothing to research" in last["report"]
def test_empty_topic_is_rejected(fake_llm, embedder):
events = list(pipeline.run_research(" ", searcher=fake_searcher, embedder=embedder, llm_api=fake_llm))
assert events[-1]["error"]
def test_search_failure_without_documents_surfaces_message(fake_llm, embedder):
from src.search import SearchError
def broken(q, max_results=4, search_depth="basic"):
raise SearchError("`TAVILY_API_KEY` is missing")
events = list(pipeline.run_research("topic here", searcher=broken, embedder=embedder, llm_api=fake_llm))
assert events[-1]["error"] and "TAVILY_API_KEY" in events[-1]["report"]
def test_writer_failure_is_reported_not_raised(embedder, fake_llm):
import types
from src.llm import LLMError
def bad_stream(*a, **k):
raise LLMError("All Groq models failed.")
yield
broken = types.SimpleNamespace(complete=fake_llm.complete, stream=bad_stream)
events = list(pipeline.run_research("topic here", searcher=fake_searcher, embedder=embedder, llm_api=broken))
assert events[-1]["error"] and "All Groq models failed" in events[-1]["report"]
def test_checker_outage_still_delivers_report(embedder, fake_llm):
import types
from src.llm import LLMError
def complete(messages, role="fast", temperature=0.0, json_mode=False):
if "Planner" in messages[0]["content"]:
return fake_llm.complete(messages, role, temperature, json_mode)
raise LLMError("rate limited")
llm_api = types.SimpleNamespace(complete=complete, stream=fake_llm.stream)
_, last = None, list(pipeline.run_research("topic here", searcher=fake_searcher, embedder=embedder, llm_api=llm_api))[-1]
assert last["done"] and "unavailable" in last["result"]["report"]
def test_reviser_guard_rejects_stub_rewrites(embedder):
report = "word " * 200
claims = [{"id": 1, "text": "x [1]", "cites": [1]}]
results = {1: {"verdict": "unsupported", "reason": "r"}}
class Store:
def support_for(self, *a, **k):
return ["evidence"]
assert agents.revise(report, claims, results, Store(), lambda *a, **k: "ok") == report
# ---------------------------------------------------------- documents
def test_load_documents_pdf_txt_and_warnings(tmp_path):
from reportlab.lib.pagesizes import LETTER
from reportlab.platypus import Paragraph, SimpleDocTemplate
from reportlab.lib.styles import getSampleStyleSheet
pdf = tmp_path / "paper.pdf"
SimpleDocTemplate(str(pdf), pagesize=LETTER).build(
[Paragraph("Agrivoltaics lets farmers grow crops under solar panels and earn extra income. " * 5, getSampleStyleSheet()["BodyText"])]
)
txt = tmp_path / "empty.txt"
txt.write_text("hi")
odd = tmp_path / "data.xyz"
odd.write_text("whatever " * 30)
docs, warnings = load_documents([str(pdf), str(txt), str(odd)])
assert [d["title"] for d in docs] == ["paper.pdf"]
assert "Agrivoltaics" in docs[0]["content"] and docs[0]["kind"] == "document"
assert len(warnings) == 2
# ---------------------------------------------------------- evaluation
def test_evaluate_aggregates_and_writes_reports(fake_llm, embedder, tmp_path):
import functools
import evaluate
runner = functools.partial(pipeline.run_research, searcher=fake_searcher, embedder=embedder, llm_api=fake_llm)
rows = [evaluate.evaluate_topic("AI in agriculture", "Quick", runner)]
rows.append(evaluate.evaluate_topic("x", "Quick", runner)) # too short -> failure row
assert rows[0]["ok"] and rows[0]["support_first"] < rows[0]["support_final"] == 1.0
assert not rows[1]["ok"]
summary = evaluate.aggregate(rows)
assert summary["completed"] == 1 and summary["revised_count"] == 1
evaluate.write_outputs(rows, summary, str(tmp_path), "Quick")
text = (tmp_path / "RESULTS.md").read_text(encoding="utf-8")
assert "first draft" in text and "100%" in text and (tmp_path / "results.json").exists()