Ctrl K
ComunidadGuías…

tests/test_off_topic_filter.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Tests for `_filter_off_topic_sources`.

Acceptance:
  - the pattern heuristic `_is_listing_page` stays as a first stage.
  - the classifier `judge_source_relevance` complements it semantically:
    what the heuristic misses, the classifier checks.
  - a use case can disable the classifier.
  - on an LLM error or without a plan, all sources are kept
    (conservative default — nothing is lost silently).

The tests run without booting the full orchestrator, by calling the method
via `__class__` binding on a stub.
"""

import unittest
from unittest.mock import MagicMock

from src.pipeline.models import (
    ResearchPlan,
    ResearchQuestion,
    SourceDocument,
    SourceType,
)
from tests._helpers import MockLLM, async_test


def _make_orchestrator_stub(mode: str = "web_research"):
    from src.pipeline.orchestrator import ResearchOrchestrator
    stub = MagicMock(spec=ResearchOrchestrator)
    stub._mode = mode
    stub._classifier_calls = []
    stub.llm = MagicMock()
    return stub


def _make_source(url: str, title: str, content: str = "x" * 600):
    return SourceDocument(SourceType.WEB_PAGE, url, title, content)


def _patch_adapter_with(llm: MockLLM):
    """Patch HarvestModelAdapter so that it uses the MockLLM."""
    from src.llm import classifier_adapter

    class _Pass:
        def __init__(self, _dual):
            pass
        async def complete(self, messages, max_tokens=None):
            return await llm.complete(messages, max_tokens=max_tokens)

    original = classifier_adapter.HarvestModelAdapter
    classifier_adapter.HarvestModelAdapter = _Pass
    return original  # gives the test the possibility to patch back


def _restore_adapter(original):
    from src.llm import classifier_adapter
    classifier_adapter.HarvestModelAdapter = original


class TestOffTopicFilter(unittest.TestCase):

    @async_test
    async def test_off_topic_with_high_confidence_filtered(self):
        """off_topic with conf>0.7 is excluded."""
        from src.pipeline.orchestrator import ResearchOrchestrator

        # 3 sources: 2 relevant, 1 clearly off topic
        sources = [
            _make_source("https://nvidia.com/datasheet", "DGX B300 Datasheet"),
            _make_source("https://servethehome.com/dgx-review", "DGX B300 Review"),
            _make_source("https://example.com/cooking", "Recipes for Pasta"),
        ]
        plan = ResearchPlan(
            questions=[
                ResearchQuestion(id="F1", question="DGX B300 Stromaufnahme"),
            ],
            summary="",
        )

        # LLM answer: only item 3 is off topic with high confidence
        llm = MockLLM().queue_json([
            {"index": 1, "relevance": "high", "confidence": 0.9,
             "reasoning": "Datasheet ist Primärquelle"},
            {"index": 2, "relevance": "high", "confidence": 0.85,
             "reasoning": "Review enthält Power-Tests"},
            {"index": 3, "relevance": "off_topic", "confidence": 0.95,
             "reasoning": "Kochrezepte, kein Bezug zu Hardware"},
        ])

        original = _patch_adapter_with(llm)
        try:
            stub = _make_orchestrator_stub()
            progress_cb = MagicMock(return_value=None)

            async def progress(*a, **kw):
                progress_cb(*a, **kw)

            kept = await ResearchOrchestrator._filter_off_topic_sources(
                stub, sources, plan, progress,
            )
        finally:
            _restore_adapter(original)

        self.assertEqual(len(kept), 2)
        kept_urls = {s.url for s in kept}
        self.assertIn("https://nvidia.com/datasheet", kept_urls)
        self.assertIn("https://servethehome.com/dgx-review", kept_urls)
        self.assertNotIn("https://example.com/cooking", kept_urls)

    @async_test
    async def test_low_confidence_off_topic_kept(self):
        """Off topic with low confidence stays — safe default."""
        from src.pipeline.orchestrator import ResearchOrchestrator

        sources = [_make_source("https://x.com/page", "Vielleicht relevant")]
        plan = ResearchPlan(
            questions=[ResearchQuestion(id="F1", question="x")],
            summary="",
        )

        llm = MockLLM().queue_json([
            {"index": 1, "relevance": "off_topic", "confidence": 0.5,
             "reasoning": "Bin mir nicht ganz sicher"},
        ])

        original = _patch_adapter_with(llm)
        try:
            stub = _make_orchestrator_stub()
            kept = await ResearchOrchestrator._filter_off_topic_sources(
                stub, sources, plan,
                lambda *a, **kw: None,
            )
        finally:
            _restore_adapter(original)

        # kept — the classifier is not sure
        self.assertEqual(len(kept), 1)

    @async_test
    async def test_no_questions_no_filtering(self):
        """Plan without questions → no classifier call, all sources stay."""
        from src.pipeline.orchestrator import ResearchOrchestrator

        sources = [_make_source("https://x.com", "x")]
        plan = ResearchPlan(questions=[], summary="")

        stub = _make_orchestrator_stub()
        kept = await ResearchOrchestrator._filter_off_topic_sources(
            stub, sources, plan, lambda *a, **kw: None,
        )

        self.assertEqual(kept, sources)

    @async_test
    async def test_empty_sources(self):
        from src.pipeline.orchestrator import ResearchOrchestrator
        stub = _make_orchestrator_stub()
        plan = ResearchPlan(
            questions=[ResearchQuestion(id="F1", question="x")],
            summary="",
        )

        kept = await ResearchOrchestrator._filter_off_topic_sources(
            stub, [], plan, lambda *a, **kw: None,
        )
        self.assertEqual(kept, [])

    @async_test
    async def test_llm_failure_keeps_all(self):
        """LLM failure → all sources stay (conservative default)."""
        from src.pipeline.orchestrator import ResearchOrchestrator

        sources = [_make_source(f"https://x{i}.com", "x") for i in range(3)]
        plan = ResearchPlan(
            questions=[ResearchQuestion(id="F1", question="x")],
            summary="",
        )

        from src.llm import classifier_adapter

        class _BrokenAdapter:
            def __init__(self, _dual): pass
            async def complete(self, messages, max_tokens=None):
                raise RuntimeError("LLM down")

        # Since the classifier itself (judge_source_relevance) catches the
        # LLM failure and assigns 'medium' as the default, the WRAPPER path
        # in _filter_off_topic_sources should be robust too. The batched
        # classifier does a try/except in every batch.
        original = classifier_adapter.HarvestModelAdapter
        classifier_adapter.HarvestModelAdapter = _BrokenAdapter
        try:
            stub = _make_orchestrator_stub()
            kept = await ResearchOrchestrator._filter_off_topic_sources(
                stub, sources, plan, lambda *a, **kw: None,
            )
        finally:
            classifier_adapter.HarvestModelAdapter = original

        # the classifier-internal fallback gives everything 'medium' →
        # all sources stay
        self.assertEqual(len(kept), 3)

    @async_test
    async def test_classifier_calls_logged(self):
        """Classifier calls are recorded in the stub attribute."""
        from src.pipeline.orchestrator import ResearchOrchestrator

        sources = [_make_source(f"https://x{i}.com", "t") for i in range(3)]
        plan = ResearchPlan(
            questions=[ResearchQuestion(id="F1", question="x")],
            summary="",
        )

        llm = MockLLM().queue_json([
            {"index": i + 1, "relevance": "medium", "confidence": 0.6,
             "reasoning": "x"}
            for i in range(3)
        ])

        original = _patch_adapter_with(llm)
        try:
            stub = _make_orchestrator_stub()
            stub._classifier_calls = []  # fresh
            await ResearchOrchestrator._filter_off_topic_sources(
                stub, sources, plan, lambda *a, **kw: None,
            )
        finally:
            _restore_adapter(original)

        # at least one classifier call was logged
        self.assertGreater(len(stub._classifier_calls), 0)


if __name__ == "__main__":
    unittest.main()