Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
313
ai_platform/modules/web/tests/test_evidence_packer.py
Normal file
313
ai_platform/modules/web/tests/test_evidence_packer.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Tests for evidence packer - deduplication, credibility scoring, circuit breaker."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from web.config import WebSettings
|
||||
from web.evidence.packer import EvidencePacker
|
||||
from web.schemas.common import PageContent
|
||||
from web.schemas.evidence import EvidencePackRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_settings() -> WebSettings:
|
||||
"""Create test settings."""
|
||||
return WebSettings(
|
||||
searxng_base_url="http://localhost:55100",
|
||||
llm_base_url="http://localhost:14011",
|
||||
external_url="http://localhost:51100",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def packer(test_settings: WebSettings) -> EvidencePacker:
|
||||
"""Create an EvidencePacker instance."""
|
||||
return EvidencePacker(test_settings)
|
||||
|
||||
|
||||
class TestDeduplication:
|
||||
"""Tests for content deduplication."""
|
||||
|
||||
def test_identical_texts_high_similarity(self, packer: EvidencePacker) -> None:
|
||||
"""Test that identical texts have very high similarity."""
|
||||
text = "This is a test document about climate change effects."
|
||||
score = packer._calculate_similarity(text, text)
|
||||
assert score >= 0.95
|
||||
|
||||
def test_different_texts_low_similarity(self, packer: EvidencePacker) -> None:
|
||||
"""Test that very different texts have low similarity."""
|
||||
text1 = "Climate change is affecting global temperatures."
|
||||
text2 = "The stock market rallied yesterday on positive earnings."
|
||||
score = packer._calculate_similarity(text1, text2)
|
||||
assert score < 0.5
|
||||
|
||||
def test_empty_texts_zero_similarity(self, packer: EvidencePacker) -> None:
|
||||
"""Test that empty texts return zero similarity."""
|
||||
assert packer._calculate_similarity("", "") == 0.0
|
||||
assert packer._calculate_similarity("text", "") == 0.0
|
||||
assert packer._calculate_similarity("", "text") == 0.0
|
||||
|
||||
def test_length_ratio_prefilter(self, packer: EvidencePacker) -> None:
|
||||
"""Test that very different length texts get low similarity via pre-filter."""
|
||||
short = "Hello"
|
||||
long = "This is a very long text " * 100
|
||||
score = packer._calculate_similarity(short, long)
|
||||
assert score < 0.5
|
||||
|
||||
|
||||
class TestCredibilityScoring:
|
||||
"""Tests for credibility scoring."""
|
||||
|
||||
def test_high_credibility_domain(self, packer: EvidencePacker) -> None:
|
||||
"""Test that known credible domains get high scores."""
|
||||
score = packer._score_credibility_simple("https://reuters.com/article/123")
|
||||
assert score == 0.9
|
||||
|
||||
def test_high_credibility_with_www(self, packer: EvidencePacker) -> None:
|
||||
"""Test that www prefix is stripped."""
|
||||
score = packer._score_credibility_simple("https://www.bbc.com/news/article")
|
||||
assert score == 0.9
|
||||
|
||||
def test_medium_credibility_domain(self, packer: EvidencePacker) -> None:
|
||||
"""Test that medium-credibility domains get appropriate scores."""
|
||||
score = packer._score_credibility_simple("https://cnn.com/article")
|
||||
assert score == 0.7
|
||||
|
||||
def test_unknown_domain_default_score(self, packer: EvidencePacker) -> None:
|
||||
"""Test that unknown domains get default score."""
|
||||
score = packer._score_credibility_simple("https://random-blog.com/post")
|
||||
assert score == 0.5
|
||||
|
||||
def test_subdomain_does_not_match(self, packer: EvidencePacker) -> None:
|
||||
"""Test that 'notreuters.com' does not match 'reuters.com'."""
|
||||
score = packer._score_credibility_simple("https://notreuters.com/article")
|
||||
assert score == 0.5
|
||||
|
||||
def test_subdomain_matches(self, packer: EvidencePacker) -> None:
|
||||
"""Test that 'news.bbc.co.uk' matches 'bbc.co.uk'."""
|
||||
score = packer._score_credibility_simple("https://news.bbc.co.uk/article")
|
||||
assert score == 0.9
|
||||
|
||||
|
||||
class TestThinkingTagStripping:
|
||||
"""Tests for LLM thinking tag removal."""
|
||||
|
||||
def test_strips_complete_think_tags(self, packer: EvidencePacker) -> None:
|
||||
"""Test stripping complete <think>...</think> blocks."""
|
||||
text = "<think>Let me analyze this...</think>The answer is 42."
|
||||
result = packer._strip_thinking_tags(text)
|
||||
assert result == "The answer is 42."
|
||||
|
||||
def test_strips_truncated_think_tags(self, packer: EvidencePacker) -> None:
|
||||
"""Test stripping truncated <think> without closing tag."""
|
||||
text = "<think>Still thinking about this"
|
||||
result = packer._strip_thinking_tags(text)
|
||||
assert result == ""
|
||||
|
||||
def test_preserves_text_without_tags(self, packer: EvidencePacker) -> None:
|
||||
"""Test that text without think tags is preserved."""
|
||||
text = "No thinking tags here."
|
||||
result = packer._strip_thinking_tags(text)
|
||||
assert result == "No thinking tags here."
|
||||
|
||||
|
||||
class TestLLMCircuitBreaker:
|
||||
"""Tests for LLM availability probe / circuit breaker."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_marks_unavailable_on_connection_error(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that a connection error marks LLM as unavailable."""
|
||||
with patch("web.evidence.packer.httpx.AsyncClient") as mock_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = httpx.ConnectError("refused")
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
result = await packer._is_llm_available()
|
||||
|
||||
assert result is False
|
||||
assert packer._llm_available is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_marks_available_on_success(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that a successful response marks LLM as available."""
|
||||
with patch("web.evidence.packer.httpx.AsyncClient") as mock_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.raise_for_status = lambda: None
|
||||
mock_client.get.return_value = mock_resp
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
result = await packer._is_llm_available()
|
||||
|
||||
assert result is True
|
||||
assert packer._llm_available is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pack_disables_llm_when_unreachable(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that pack() disables LLM features when probe fails."""
|
||||
page = PageContent(
|
||||
url="https://example.com/article",
|
||||
title="Test",
|
||||
text="Enough content to process for evidence packing test." * 5,
|
||||
text_hash="abc123",
|
||||
extraction_method="http",
|
||||
retrieved_at="2025-01-01T00:00:00Z",
|
||||
extraction_time_ms=100.0,
|
||||
)
|
||||
request = EvidencePackRequest(
|
||||
pages=[page],
|
||||
claim="Test claim for circuit breaker verification",
|
||||
score_relevance=True,
|
||||
extract_snippets=False,
|
||||
)
|
||||
|
||||
# Make probe fail
|
||||
with patch.object(
|
||||
packer, "_is_llm_available", new_callable=AsyncMock, return_value=False
|
||||
):
|
||||
response = await packer.pack(request)
|
||||
|
||||
# Should succeed without LLM calls (no errors)
|
||||
assert len(response.evidence) == 1
|
||||
# No LLM tokens used since LLM was disabled
|
||||
assert response.stats.tokens_used == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_cache_prevents_repeated_probes(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that negative cache prevents repeated probe calls."""
|
||||
with patch("web.evidence.packer.httpx.AsyncClient") as mock_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = httpx.ConnectError("refused")
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
# First call: probes and caches negative result
|
||||
result1 = await packer._is_llm_available()
|
||||
assert result1 is False
|
||||
|
||||
# Second call: should use cached result (no new probe)
|
||||
mock_cls.reset_mock()
|
||||
result2 = await packer._is_llm_available()
|
||||
assert result2 is False
|
||||
mock_cls.assert_not_called()
|
||||
|
||||
|
||||
class TestSummarize:
|
||||
"""Tests for LLM summarization feature."""
|
||||
|
||||
def test_parse_batch_summary_extracts_correct_summary(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test parsing individual summaries from batch response."""
|
||||
response = (
|
||||
"SUMMARY 1: This is the first summary about economics.\n"
|
||||
"SUMMARY 2: This is the second summary about politics."
|
||||
)
|
||||
result1 = packer._parse_batch_summary(response, 1, 800)
|
||||
result2 = packer._parse_batch_summary(response, 2, 800)
|
||||
assert result1 is not None
|
||||
assert "first summary" in result1
|
||||
assert result2 is not None
|
||||
assert "second summary" in result2
|
||||
|
||||
def test_parse_batch_summary_returns_none_for_missing(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that missing summary number returns None."""
|
||||
response = "SUMMARY 1: Only one summary here."
|
||||
result = packer._parse_batch_summary(response, 3, 800)
|
||||
assert result is None
|
||||
|
||||
def test_parse_batch_summary_respects_max_length(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that summaries are truncated to max_length."""
|
||||
long_text = "A" * 1000
|
||||
response = f"SUMMARY 1: {long_text}"
|
||||
result = packer._parse_batch_summary(response, 1, 200)
|
||||
assert result is not None
|
||||
assert len(result) <= 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pack_disables_summarize_when_llm_unreachable(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that pack() disables summarize when LLM probe fails."""
|
||||
page = PageContent(
|
||||
url="https://example.com/article",
|
||||
title="Test",
|
||||
text="Content about economic growth in Romania." * 10,
|
||||
text_hash="summ123",
|
||||
extraction_method="http",
|
||||
retrieved_at="2025-01-01T00:00:00Z",
|
||||
extraction_time_ms=100.0,
|
||||
)
|
||||
request = EvidencePackRequest(
|
||||
pages=[page],
|
||||
claim="Romania had the highest economic growth in the EU in 2024",
|
||||
summarize=True,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
packer, "_is_llm_available", new_callable=AsyncMock, return_value=False
|
||||
):
|
||||
response = await packer.pack(request)
|
||||
|
||||
assert len(response.evidence) == 1
|
||||
# Summary should be None since LLM was disabled
|
||||
assert response.evidence[0].summary is None
|
||||
assert response.stats.tokens_used == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarize_produces_summary_field(
|
||||
self, packer: EvidencePacker
|
||||
) -> None:
|
||||
"""Test that summarize=True produces a summary field via LLM."""
|
||||
page = PageContent(
|
||||
url="https://example.com/article",
|
||||
title="Test Article",
|
||||
text="Romania's GDP grew by 4.1% in 2024, the highest in the EU." * 5,
|
||||
text_hash="summ456",
|
||||
extraction_method="http",
|
||||
retrieved_at="2025-01-01T00:00:00Z",
|
||||
extraction_time_ms=100.0,
|
||||
)
|
||||
request = EvidencePackRequest(
|
||||
pages=[page],
|
||||
claim="Romania had the highest economic growth in the EU in 2024",
|
||||
summarize=True,
|
||||
)
|
||||
|
||||
mock_summary = "Romania achieved 4.1% GDP growth in 2024, leading the EU."
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
packer, "_is_llm_available", new_callable=AsyncMock, return_value=True
|
||||
),
|
||||
patch.object(
|
||||
packer,
|
||||
"_summarize_single",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(mock_summary, 150),
|
||||
),
|
||||
):
|
||||
response = await packer.pack(request)
|
||||
|
||||
assert len(response.evidence) == 1
|
||||
assert response.evidence[0].summary == mock_summary
|
||||
assert response.stats.tokens_used == 150
|
||||
Loading…
Add table
Add a link
Reference in a new issue