Nomearod commited on
Commit
4ec6632
·
1 Parent(s): 4fb5dcb

feat: multi-corpus construction with nested corpus×provider composition

Browse files

Builds corpus_map[corpus][provider] at app startup. Store, retriever,
and search tool are shared across providers within a corpus; only the
Orchestrator differs per provider since it holds the LLM client. RSS
logged once per corpus (not per corpus × provider — store drives
memory). Mode log line identifies multi-corpus vs legacy mode. Default
orchestrator points at default_corpus × default_provider.

Deviation from plan: nested dict instead of flat. Rationale: provider
toggle must continue to work in multi-corpus mode — the provider
comparison is the headline benchmark finding and a user-visible
silent-failure would erode the demo's credibility.

agent_bench/serving/app.py CHANGED
@@ -128,7 +128,7 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
128
  )
129
  registry.register(CalculatorTool())
130
 
131
- # Orchestrators — one per available provider
132
  orchestrators: dict = {}
133
  for name, prov in providers.items():
134
  orchestrators[name] = Orchestrator(
@@ -139,6 +139,88 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
139
  )
140
  orchestrator = orchestrators[config.provider.default]
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  # Metrics
143
  metrics = MetricsCollector()
144
 
@@ -152,6 +234,7 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
152
  # Attach to app state
153
  app.state.orchestrator = orchestrator
154
  app.state.orchestrators = orchestrators
 
155
  app.state.store = store
156
  app.state.conversation_store = conversation_store
157
  app.state.config = config
 
128
  )
129
  registry.register(CalculatorTool())
130
 
131
+ # Orchestrators — one per available provider (legacy flat dict)
132
  orchestrators: dict = {}
133
  for name, prov in providers.items():
134
  orchestrators[name] = Orchestrator(
 
139
  )
140
  orchestrator = orchestrators[config.provider.default]
141
 
142
+ # Multi-corpus construction: nested corpus_map[corpus][provider].
143
+ # Each corpus has its own store/retriever/search tool (shared across
144
+ # providers within the corpus). Only the Orchestrator differs per provider
145
+ # since it holds the LLM client.
146
+ corpus_map: dict[str, dict[str, Orchestrator]] = {}
147
+ if config.corpora:
148
+ import psutil
149
+ import structlog
150
+
151
+ _proc = psutil.Process()
152
+ _baseline_rss = _proc.memory_info().rss / 1024**2
153
+ _log = structlog.get_logger()
154
+
155
+ for corpus_name, corpus_cfg in config.corpora.items():
156
+ # Per-corpus store (may fall back to empty if no files on disk)
157
+ c_store_path = Path(corpus_cfg.store_path)
158
+ if c_store_path.exists() and (c_store_path / "index.faiss").exists():
159
+ c_store = HybridStore.load(
160
+ str(c_store_path), rrf_k=config.rag.retrieval.rrf_k,
161
+ )
162
+ else:
163
+ c_store = HybridStore(
164
+ dimension=384, rrf_k=config.rag.retrieval.rrf_k,
165
+ )
166
+
167
+ c_retriever = Retriever(
168
+ embedder=embedder,
169
+ store=c_store,
170
+ default_strategy=config.rag.retrieval.strategy, # type: ignore[arg-type]
171
+ candidates_per_system=config.rag.retrieval.candidates_per_system,
172
+ reranker=reranker,
173
+ reranker_top_k=config.rag.reranker.top_k,
174
+ )
175
+ c_registry = ToolRegistry()
176
+ c_registry.register(
177
+ SearchTool(
178
+ retriever=c_retriever,
179
+ default_top_k=corpus_cfg.top_k,
180
+ default_strategy=config.rag.retrieval.strategy,
181
+ refusal_threshold=corpus_cfg.refusal_threshold,
182
+ pii_redactor=pii_redactor if sec.pii.enabled else None,
183
+ )
184
+ )
185
+ c_registry.register(CalculatorTool())
186
+
187
+ # One orchestrator per available provider, sharing the registry.
188
+ inner: dict[str, Orchestrator] = {}
189
+ for p_name, p_prov in providers.items():
190
+ inner[p_name] = Orchestrator(
191
+ provider=p_prov,
192
+ registry=c_registry,
193
+ max_iterations=corpus_cfg.max_iterations,
194
+ temperature=config.agent.temperature,
195
+ )
196
+ corpus_map[corpus_name] = inner
197
+
198
+ _rss_mb = _proc.memory_info().rss / 1024**2
199
+ _log.info(
200
+ "corpus_loaded",
201
+ name=corpus_name,
202
+ label=corpus_cfg.label,
203
+ store_path=str(c_store_path),
204
+ providers=list(inner.keys()),
205
+ rss_mb=round(_rss_mb, 1),
206
+ rss_delta_mb=round(_rss_mb - _baseline_rss, 1),
207
+ )
208
+
209
+ _log.info(
210
+ "multi_corpus_mode",
211
+ corpora=list(corpus_map.keys()),
212
+ default=config.default_corpus,
213
+ providers=list(providers.keys()),
214
+ )
215
+ # Default orchestrator is the default corpus × default provider.
216
+ if config.default_corpus in corpus_map:
217
+ default_inner = corpus_map[config.default_corpus]
218
+ if config.provider.default in default_inner:
219
+ orchestrator = default_inner[config.provider.default]
220
+ else:
221
+ import structlog
222
+ structlog.get_logger().info("single_corpus_mode_legacy")
223
+
224
  # Metrics
225
  metrics = MetricsCollector()
226
 
 
234
  # Attach to app state
235
  app.state.orchestrator = orchestrator
236
  app.state.orchestrators = orchestrators
237
+ app.state.corpus_map = corpus_map
238
  app.state.store = store
239
  app.state.conversation_store = conversation_store
240
  app.state.config = config
tests/test_app_corpus_map.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for multi-corpus construction at app startup."""
2
+
3
+ import pytest
4
+
5
+ from agent_bench.core.config import (
6
+ AppConfig,
7
+ CorpusConfig,
8
+ EmbeddingConfig,
9
+ ProviderConfig,
10
+ RAGConfig,
11
+ )
12
+ from agent_bench.serving.app import create_app
13
+
14
+
15
+ @pytest.fixture
16
+ def multi_corpus_config(tmp_path):
17
+ """Config with two corpora pointing at empty store paths."""
18
+ # Neither store exists on disk, so create_app falls back to empty stores
19
+ return AppConfig(
20
+ provider=ProviderConfig(default="mock"),
21
+ rag=RAGConfig(store_path=str(tmp_path / "store_default")),
22
+ embedding=EmbeddingConfig(cache_dir=str(tmp_path / "emb_cache")),
23
+ corpora={
24
+ "fastapi": CorpusConfig(
25
+ label="FastAPI Docs",
26
+ store_path=str(tmp_path / "store_fastapi"),
27
+ data_path="data/tech_docs",
28
+ refusal_threshold=0.35,
29
+ ),
30
+ "k8s": CorpusConfig(
31
+ label="Kubernetes",
32
+ store_path=str(tmp_path / "store_k8s"),
33
+ data_path="data/k8s_docs",
34
+ refusal_threshold=0.30,
35
+ ),
36
+ },
37
+ default_corpus="fastapi",
38
+ )
39
+
40
+
41
+ def test_corpus_map_keys_match_config(multi_corpus_config):
42
+ """app.state.corpus_map is keyed by corpus names."""
43
+ app = create_app(multi_corpus_config)
44
+ assert set(app.state.corpus_map.keys()) == {"fastapi", "k8s"}
45
+
46
+
47
+ def test_corpus_map_inner_dict_keyed_by_provider(multi_corpus_config):
48
+ """Each corpus entry is a dict keyed by provider name (nested composition)."""
49
+ app = create_app(multi_corpus_config)
50
+ # Mock provider is the only one registered (no API keys set)
51
+ for corpus_name in ("fastapi", "k8s"):
52
+ inner = app.state.corpus_map[corpus_name]
53
+ assert isinstance(inner, dict)
54
+ assert "mock" in inner
55
+ # Every inner dict has the same provider keys
56
+ assert set(inner.keys()) == set(app.state.corpus_map["fastapi"].keys())
57
+
58
+
59
+ def test_default_orchestrator_points_at_default_corpus_and_provider(multi_corpus_config):
60
+ """app.state.orchestrator == corpus_map[default_corpus][default_provider]."""
61
+ app = create_app(multi_corpus_config)
62
+ assert (
63
+ app.state.orchestrator
64
+ is app.state.corpus_map["fastapi"]["mock"]
65
+ )
66
+
67
+
68
+ def test_legacy_mode_has_empty_corpus_map():
69
+ """If config.corpora is empty, corpus_map is empty too."""
70
+ config = AppConfig(provider=ProviderConfig(default="mock"))
71
+ app = create_app(config)
72
+ assert app.state.corpus_map == {}
73
+ # Legacy orchestrator still attached
74
+ assert app.state.orchestrator is not None