fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
178 lines
7.2 KiB
Python
178 lines
7.2 KiB
Python
"""Tests for per-source search exposure partitioning (E1 / D11)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
from application.storage.db.source_config import RetrievalConfig
|
|
|
|
|
|
def _processor() -> StreamProcessor:
|
|
"""A bare StreamProcessor with only the fields the exposure helpers touch.
|
|
|
|
Built via ``__new__`` to skip the DB-heavy ``__init__``; the exposure
|
|
methods read ``all_sources`` / ``source`` / ``agent_config`` only.
|
|
"""
|
|
sp = StreamProcessor.__new__(StreamProcessor)
|
|
sp.all_sources = []
|
|
sp.source = {}
|
|
sp.agent_config = {}
|
|
sp.retriever_config = {"retriever_name": "classic", "chunks": 2, "doc_token_limit": 50000}
|
|
sp.data = {}
|
|
return sp
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExposurePartition:
|
|
def test_no_config_all_default_to_prefetch(self):
|
|
sp = _processor()
|
|
sp.all_sources = [{"id": "a", "retrieval": None}, {"id": "b", "retrieval": None}]
|
|
prefetch, agentic = sp._exposure_partition()
|
|
assert [e["id"] for e in prefetch] == ["a", "b"]
|
|
assert agentic == []
|
|
|
|
def test_mixed_partition(self):
|
|
sp = _processor()
|
|
sp.all_sources = [
|
|
{"id": "a", "retrieval": RetrievalConfig(exposure="prefetch")},
|
|
{"id": "b", "retrieval": RetrievalConfig(exposure="agentic_tool")},
|
|
{"id": "c", "retrieval": RetrievalConfig()}, # default prefetch
|
|
]
|
|
prefetch, agentic = sp._exposure_partition()
|
|
assert sorted(e["id"] for e in prefetch) == ["a", "c"]
|
|
assert [e["id"] for e in agentic] == ["b"]
|
|
|
|
def test_exposure_of_dict_and_model_and_missing(self):
|
|
sp = _processor()
|
|
assert sp._exposure_of(RetrievalConfig(exposure="agentic_tool")) == "agentic_tool"
|
|
assert sp._exposure_of({"exposure": "agentic_tool"}) == "agentic_tool"
|
|
assert sp._exposure_of(None) == "prefetch"
|
|
assert sp._exposure_of({}) == "prefetch"
|
|
|
|
def test_source_for_docs(self):
|
|
sp = _processor()
|
|
assert sp._source_for_docs(["a", "b"]) == {"active_docs": ["a", "b"]}
|
|
assert sp._source_for_docs([]) == {}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestBuildAgentExposure:
|
|
def _agentic_processor(self):
|
|
sp = _processor()
|
|
sp.agent_config = {"agent_type": "agentic"}
|
|
sp.initialize = MagicMock()
|
|
sp.pre_fetch_docs = MagicMock(return_value=("docs_together", ["doc"]))
|
|
sp.pre_fetch_tools = MagicMock(return_value={"tool": {}})
|
|
sp.create_agent = MagicMock(return_value="AGENT")
|
|
return sp
|
|
|
|
def test_all_prefetch_is_today_no_partition(self):
|
|
# No agentic_tool source → today's behavior: no pre-fetch, no
|
|
# agentic_sources passed (tool exposes all sources).
|
|
sp = self._agentic_processor()
|
|
sp.all_sources = [
|
|
{"id": "a", "retrieval": RetrievalConfig()},
|
|
{"id": "b", "retrieval": None},
|
|
]
|
|
result = sp.build_agent("q")
|
|
assert result == "AGENT"
|
|
sp.pre_fetch_docs.assert_not_called()
|
|
_, kwargs = sp.create_agent.call_args
|
|
assert "agentic_sources" not in kwargs
|
|
|
|
def test_mixed_prefetches_and_scopes_tool(self):
|
|
sp = self._agentic_processor()
|
|
sp.all_sources = [
|
|
{"id": "a", "retrieval": RetrievalConfig(exposure="prefetch")},
|
|
{"id": "b", "retrieval": RetrievalConfig(exposure="agentic_tool")},
|
|
]
|
|
sp.build_agent("q")
|
|
# Pre-fetch ran scoped to the prefetch subset.
|
|
sp.pre_fetch_docs.assert_called_once()
|
|
_, pf_kwargs = sp.pre_fetch_docs.call_args
|
|
assert pf_kwargs.get("exposure") == "prefetch"
|
|
# create_agent received only the agentic_tool subset as the tool sources.
|
|
_, kwargs = sp.create_agent.call_args
|
|
assert [e["id"] for e in kwargs["agentic_sources"]] == ["b"]
|
|
|
|
def _classic_processor(self):
|
|
sp = _processor()
|
|
sp.agent_config = {"agent_type": "classic"}
|
|
sp.initialize = MagicMock()
|
|
sp.pre_fetch_docs = MagicMock(return_value=("docs_together", ["doc"]))
|
|
sp.pre_fetch_tools = MagicMock(return_value=None)
|
|
sp.create_agent = MagicMock(return_value="AGENT")
|
|
return sp
|
|
|
|
def test_classic_default_prefetches_all_no_partition(self):
|
|
# Default classic (all prefetch / no config): byte-identical to today —
|
|
# unscoped pre-fetch and no agentic_sources, so no search tool is added.
|
|
sp = self._classic_processor()
|
|
sp.all_sources = [
|
|
{"id": "a", "retrieval": RetrievalConfig()},
|
|
{"id": "b", "retrieval": None},
|
|
]
|
|
result = sp.build_agent("q")
|
|
assert result == "AGENT"
|
|
sp.pre_fetch_docs.assert_called_once_with("q")
|
|
_, kwargs = sp.create_agent.call_args
|
|
assert "agentic_sources" not in kwargs
|
|
|
|
def test_classic_no_per_source_detail_is_today(self):
|
|
# Single-source / no-config requests carry no per-source detail; classic
|
|
# must fall back to the unscoped pre-fetch (no exposure scoping).
|
|
sp = self._classic_processor()
|
|
sp.all_sources = []
|
|
sp.source = {"active_docs": ["a"]}
|
|
sp.build_agent("q")
|
|
sp.pre_fetch_docs.assert_called_once_with("q")
|
|
_, kwargs = sp.create_agent.call_args
|
|
assert "agentic_sources" not in kwargs
|
|
|
|
def test_classic_mixed_prefetches_and_scopes_tool(self):
|
|
# Classic with an agentic_tool source now pre-fetches only the prefetch
|
|
# subset and exposes the agentic_tool subset via the search tool.
|
|
sp = self._classic_processor()
|
|
sp.all_sources = [
|
|
{"id": "a", "retrieval": RetrievalConfig(exposure="prefetch")},
|
|
{"id": "b", "retrieval": RetrievalConfig(exposure="agentic_tool")},
|
|
]
|
|
sp.build_agent("q")
|
|
sp.pre_fetch_docs.assert_called_once()
|
|
_, pf_kwargs = sp.pre_fetch_docs.call_args
|
|
assert pf_kwargs.get("exposure") == "prefetch"
|
|
_, kwargs = sp.create_agent.call_args
|
|
assert [e["id"] for e in kwargs["agentic_sources"]] == ["b"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNonAgentSourceConfig:
|
|
"""The non-agent chat path must also load per-source config so exposure
|
|
(and other per-source overrides) are honored, not just the agent path."""
|
|
|
|
def test_active_docs_loads_per_source_retrieval_config(self):
|
|
sp = StreamProcessor.__new__(StreamProcessor)
|
|
sp._agent_data = None
|
|
sp.data = {"active_docs": "s1"}
|
|
sp.initial_user_id = "user1"
|
|
sp.source = {}
|
|
sp.all_sources = []
|
|
fake_source = {
|
|
"config": {"retrieval": {"exposure": "agentic_tool", "chunks": 7}}
|
|
}
|
|
with patch(
|
|
"application.api.answer.services.stream_processor.db_readonly"
|
|
), patch(
|
|
"application.api.answer.services.stream_processor.SourcesRepository"
|
|
) as repo:
|
|
repo.return_value.get.return_value = fake_source
|
|
sp._configure_source()
|
|
assert sp.source == {"active_docs": "s1"}
|
|
assert len(sp.all_sources) == 1
|
|
assert sp.all_sources[0]["id"] == "s1"
|
|
assert sp.all_sources[0]["retrieval"].exposure == "agentic_tool"
|
|
assert sp.all_sources[0]["retrieval"].chunks == 7
|