import pytest from unittest.mock import AsyncMock from agents.base_agent import AgentStatus from agents.scoring_agent import ScoringAgent from agents.argumentation_agent import ArgumentationAgent from agents.citation_relevance_agent import CitationRelevanceAgent from agents.novelty_agent import NoveltyAgent from agents.similarity_agent import SimilarityAgent @pytest.mark.asyncio async def test_scoring_agent_returns_result(agent_ctx, mock_neo4j): """ScoringAgent should return scores even when graph returns zeros.""" mock_neo4j.run.return_value = [ {"avg_score": 0.75, "total": 5} ] agent = ScoringAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert "overall_score" in result.data or result.status != AgentStatus.FAILED @pytest.mark.asyncio async def test_argumentation_agent_fallback(agent_ctx, mock_neo4j, mock_llm): """ArgumentationAgent should gracefully fallback when no claims in graph.""" mock_neo4j.run.return_value = [] # No claims mock_llm.complete_json.return_value = {} agent = ArgumentationAgent() result = await agent.run(agent_ctx) # Should not crash; returns fallback score assert result.status in (AgentStatus.COMPLETED, AgentStatus.FAILED) @pytest.mark.asyncio async def test_base_agent_captures_errors(agent_ctx): """BaseAgent.run() should catch exceptions and return FAILED status.""" from agents.base_agent import BaseAgent from typing import Dict, Any class BrokenAgent(BaseAgent): name = "broken" wave = 1 critical = False async def execute(self, ctx) -> Dict[str, Any]: raise ValueError("Intentional error") agent = BrokenAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.FAILED assert "Intentional error" in result.error @pytest.mark.asyncio async def test_citation_relevance_flags_off_topic_refs(agent_ctx, mock_neo4j, mock_llm): """CitationRelevanceAgent should store flagged references + summary.""" doc = { "title": "A Novel Approach to Federated Learning", "abstract": "We propose a new method for federated learning.", "refs": ["Relevant ref on federated learning", "Off-topic ref about ancient history"], } mock_neo4j.run.side_effect = [ [doc], [{"term": "federated learning"}], ] mock_llm.complete_list.return_value = [ {"index": 2, "verdict": "irrelevant", "reason": "Unrelated topic", "confidence": 0.9} ] agent = CitationRelevanceAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["total"] == 2 assert result.data["flagged_count"] == 1 assert result.data["flagged"][0]["index"] == 2 writes = [c.args[0] for c in mock_neo4j.run_write.call_args_list] assert any("HAS_IRRELEVANT_CITATION" in w for w in writes) assert any("HAS_CITATION_RELEVANCE" in w for w in writes) @pytest.mark.asyncio async def test_citation_relevance_skips_without_refs(agent_ctx, mock_neo4j, mock_llm): """No references in graph → agent skips gracefully.""" mock_neo4j.run.side_effect = [ [{"title": "T", "abstract": "A", "refs": None}], ] agent = CitationRelevanceAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["total"] == 0 mock_llm.complete_list.assert_not_called() @pytest.mark.asyncio async def test_citation_relevance_filters_weak_flags(agent_ctx, mock_neo4j, mock_llm): """Low-confidence or out-of-range flags should be discarded.""" doc = { "title": "T", "abstract": "A", "refs": ["Ref one", "Ref two"], } mock_neo4j.run.side_effect = [ [doc], [{"term": "kw"}], ] mock_llm.complete_list.return_value = [ {"index": 2, "verdict": "partially_relevant", "reason": "weak", "confidence": 0.4}, {"index": 99, "verdict": "irrelevant", "reason": "out of range", "confidence": 0.9}, {"index": 1, "verdict": "relevant", "reason": "not a flag verdict", "confidence": 0.9}, ] agent = CitationRelevanceAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["flagged_count"] == 0 @pytest.mark.asyncio async def test_citation_relevance_short_final_batch_no_crash(agent_ctx, mock_neo4j, mock_llm): """Hallucinated/stale indexes in a short final batch must not crash (IndexError regression).""" refs = [f"Ref {i}" for i in range(1, 22)] # 21 refs -> batches of 10, 10, 1 mock_neo4j.run.side_effect = [ [{"title": "T", "abstract": "A", "refs": refs}], [{"term": "kw"}], ] mock_llm.complete_list.side_effect = [ [], [], [ {"index": 19, "verdict": "irrelevant", "reason": "belongs to an earlier batch", "confidence": 0.9}, {"index": "n/a", "verdict": "irrelevant", "reason": "malformed index", "confidence": 0.9}, {"index": 21, "verdict": "partially_relevant", "reason": "last ref", "confidence": 0.8}, ], ] agent = CitationRelevanceAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["total"] == 21 assert result.data["flagged_count"] == 1 assert result.data["flagged"][0]["index"] == 21 @pytest.mark.asyncio async def test_citation_relevance_survives_llm_failure(agent_ctx, mock_neo4j, mock_llm): """LLM exception → agent degrades instead of failing the pipeline.""" mock_neo4j.run.side_effect = [ [{"title": "T", "abstract": "A", "refs": ["One", "Two", "Three"]}], [{"term": "kw"}], ] mock_llm.complete_list.side_effect = RuntimeError("LLM down") agent = CitationRelevanceAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["flagged_count"] == 0 @pytest.mark.asyncio async def test_novelty_assesses_and_stores(agent_ctx, mock_neo4j, mock_llm): """NoveltyAgent should store score, verdict, problem statement, works.""" mock_neo4j.run.side_effect = [ [{"title": "Federated Learning with Privacy", "abstract": "We study privacy in FL."}], [{"term": "federated learning"}, {"term": "privacy"}], ] mock_llm.complete_json.side_effect = [ {"problem_statement": "Privacy-preserving FL on heterogeneous devices"}, { "novelty_score": 8.0, "verdict": "HIGH", "rationale": "Distinct framing with new constraints.", "similar_works": [{"title": "Old Work", "year": 2020, "url": "x"}], }, ] agent = NoveltyAgent() agent._recommender = type( "FakeRecommender", (), { "search_web": AsyncMock( return_value=[{"title": "Old Work", "year": 2020, "url": "x"}] ) }, )() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["novelty_score"] == 8.0 assert result.data["verdict"] == "HIGH" assert "privacy" in result.data["problem_statement"].lower() writes = [c.args[0] for c in mock_neo4j.run_write.call_args_list] assert any("HAS_NOVELTY" in w for w in writes) import json works_param = mock_neo4j.run_write.call_args.kwargs.get("works") assert isinstance(works_param, str) assert json.loads(works_param)[0]["title"] == "Old Work" @pytest.mark.asyncio async def test_novelty_skips_without_metadata(agent_ctx, mock_neo4j, mock_llm): """No title/abstract → agent skips gracefully.""" mock_neo4j.run.side_effect = [ [{"title": "", "abstract": ""}], ] agent = NoveltyAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["verdict"] == "UNKNOWN" mock_llm.complete_json.assert_not_called() @pytest.mark.asyncio async def test_novelty_survives_llm_and_search_failure(agent_ctx, mock_neo4j, mock_llm): """Search + LLM failures should not crash the agent.""" mock_neo4j.run.side_effect = [ [{"title": "T", "abstract": "A valid abstract for testing novelty."}], [{"term": "kw"}], ] mock_llm.complete_json.side_effect = RuntimeError("LLM down") agent = NoveltyAgent() agent._recommender = type( "FakeRecommender", (), {"search_web": AsyncMock(return_value=[])}, )() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data["verdict"] == "UNKNOWN" @pytest.mark.asyncio async def test_similarity_agent_unavailable_on_short_text(agent_ctx, mock_neo4j): """SimilarityAgent must return an 'unavailable' report, not crash, on short text.""" mock_neo4j.run.return_value = [ {"text": "a short paragraph with very few words in it"} ] agent = SimilarityAgent() result = await agent.run(agent_ctx) assert result.status == AgentStatus.COMPLETED assert result.data.get("status") == "unavailable" assert result.data.get("overall_percent") == 0