chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user