File size: 6,190 Bytes
ef5d585
 
5be9616
ef5d585
 
 
5be9616
 
 
 
ef5d585
 
 
 
 
 
5be9616
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bf7f2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""Shared test fixtures."""

import numpy as np
import pytest

from agent_bench.core.provider import MockProvider
from agent_bench.rag.chunker import Chunk
from agent_bench.rag.embedder import Embedder
from agent_bench.rag.retriever import Retriever
from agent_bench.rag.store import HybridStore


@pytest.fixture
def mock_provider() -> MockProvider:
    """MockProvider instance for deterministic testing."""
    return MockProvider()


class MockEmbeddingModel:
    """Deterministic embedding model for tests. No model download needed.

    Uses seeded random vectors, normalized to unit length.
    Same input always produces the same output via content hashing.
    """

    def __init__(self, dimension: int = 384) -> None:
        self.dimension = dimension
        self.call_count = 0

    def encode(self, sentences: list[str], **kwargs: object) -> np.ndarray:
        self.call_count += 1
        vecs = []
        for s in sentences:
            seed = int.from_bytes(s.encode()[:4], "big") % (2**31)
            rng = np.random.RandomState(seed)
            vec = rng.randn(self.dimension).astype(np.float32)
            vec = vec / np.linalg.norm(vec)
            vecs.append(vec)
        return np.stack(vecs)


@pytest.fixture
def mock_embedding_model() -> MockEmbeddingModel:
    """Deterministic embedding model — no model download."""
    return MockEmbeddingModel()


@pytest.fixture
def mock_embedder(mock_embedding_model: MockEmbeddingModel, tmp_path: object) -> Embedder:
    """Embedder backed by mock model with temp cache dir."""
    return Embedder(model=mock_embedding_model, cache_dir=str(tmp_path))


SAMPLE_CHUNKS = [
    Chunk(
        id="chunk_path_1",
        content="Path parameters in FastAPI are defined using curly braces in the URL path.",
        source="fastapi_path_params.md",
        chunk_index=0,
        metadata={"strategy": "recursive"},
    ),
    Chunk(
        id="chunk_path_2",
        content="You can declare the type of a path parameter using Python type annotations.",
        source="fastapi_path_params.md",
        chunk_index=1,
        metadata={"strategy": "recursive"},
    ),
    Chunk(
        id="chunk_query_1",
        content="Query parameters are automatically parsed from the URL query string.",
        source="fastapi_query_params.md",
        chunk_index=0,
        metadata={"strategy": "recursive"},
    ),
    Chunk(
        id="chunk_body_1",
        content="Request body data is defined using Pydantic models in FastAPI.",
        source="fastapi_request_body.md",
        chunk_index=0,
        metadata={"strategy": "recursive"},
    ),
    Chunk(
        id="chunk_response_1",
        content="Response models control the output schema of your API endpoints.",
        source="fastapi_response_model.md",
        chunk_index=0,
        metadata={"strategy": "recursive"},
    ),
]


@pytest.fixture
def sample_chunks() -> list[Chunk]:
    """5 sample chunks with known content and sources."""
    return list(SAMPLE_CHUNKS)


@pytest.fixture
def test_store(mock_embedder: Embedder, sample_chunks: list[Chunk]) -> HybridStore:
    """HybridStore populated with sample chunks via mock embedder."""
    store = HybridStore(dimension=384, rrf_k=60)
    texts = [c.content for c in sample_chunks]
    embeddings = mock_embedder.embed_batch(texts)
    store.add(sample_chunks, embeddings)
    return store


@pytest.fixture
def test_retriever(mock_embedder: Embedder, test_store: HybridStore) -> Retriever:
    """Retriever wired to mock embedder + test store."""
    return Retriever(embedder=mock_embedder, store=test_store)


# --- Multi-corpus test app (shared across routing / meta / prompt tests) ---


class _FakeOpenAI(MockProvider):
    """Distinct MockProvider subclass so tests can tell it apart from
    the default mock when asserting which orchestrator actually ran."""


@pytest.fixture
def two_corpus_two_provider_app(tmp_path, monkeypatch):
    """Two corpora (fastapi, k8s) × two providers (mock, openai-faked).

    After building the app, each corpus × provider cell gets a *unique*
    MockProvider instance tagged with `_tag`. create_app deliberately
    shares one provider instance across corpora in production (providers
    hold LLM clients and are expensive), but the test needs to distinguish
    which cell ran a given request — so the fixture breaks the sharing
    here and only here.
    """
    from agent_bench.core import provider as provider_mod
    from agent_bench.core.config import (
        AppConfig,
        CorpusConfig,
        EmbeddingConfig,
        ProviderConfig,
        RAGConfig,
        SecurityConfig,
    )
    from agent_bench.serving.app import create_app

    monkeypatch.setattr(provider_mod, "OpenAIProvider", lambda _cfg: _FakeOpenAI())
    monkeypatch.setenv("OPENAI_API_KEY", "test-key")

    config = AppConfig(
        provider=ProviderConfig(default="mock"),
        rag=RAGConfig(store_path=str(tmp_path / "store_default")),
        embedding=EmbeddingConfig(cache_dir=str(tmp_path / "emb_cache")),
        security=SecurityConfig(),
        corpora={
            "fastapi": CorpusConfig(
                label="FastAPI Docs",
                store_path=str(tmp_path / "store_fastapi"),
                data_path="data/tech_docs",
            ),
            "k8s": CorpusConfig(
                label="Kubernetes",
                store_path=str(tmp_path / "store_k8s"),
                data_path="data/k8s_docs",
            ),
        },
        default_corpus="fastapi",
    )
    app = create_app(config)

    # Stamp a unique provider into each cell so call_count is per-cell.
    for c_name, inner in app.state.corpus_map.items():
        for p_name, orch in inner.items():
            unique = MockProvider()
            unique._tag = f"{c_name}:{p_name}"  # type: ignore[attr-defined]
            orch.provider = unique
    # Keep the flat orchestrators dict and the singular orchestrator in
    # sync with the per-cell instances for the default corpus.
    app.state.orchestrators = dict(app.state.corpus_map[config.default_corpus])
    app.state.orchestrator = app.state.orchestrators[config.provider.default]
    return app