main
py 434 lines 16.1 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import json
5 import os
6 import socket
7 import subprocess
8 from contextlib import asynccontextmanager
9 from pathlib import Path
10 from uuid import UUID, uuid4
11
12 import httpx
13 import pytest
14 import uvicorn
15
16 from app.models import CompanyResearchProfile
17 from app.persistence import SqliteResearchPersistence
18 from app.research_readiness import ResearchRequirementStatus
19 from app.research_readiness_runtime import (
20 CapabilityExecutionResult,
21 RepositoryResearchReadinessAdapter,
22 ResearchReadinessRuntime,
23 )
24 from app.repository import ResearchRepository
25 from app.settings import Settings
26 from app.yahoo_mcp_acquisition import (
27 HttpExternalResearchToolGateway,
28 McpFirstResearchCapabilityExecutor,
29 )
30 from fake_yahoo import factory
31 from yahoo_mcp_server.acquisition import YahooAcquisitionService
32 from yahoo_mcp_server.audit import YahooMcpAudit
33 from yahoo_mcp_server.server import create_yahoo_mcp_server
34 from yahoo_mcp_server.settings import YahooMcpSettings
35
36
37 ROOT = Path(__file__).resolve().parents[3]
38 INSTRUMENT_ID = UUID("22222222-2222-4222-8222-222222222222")
39
40
41 class RecordingYahooAudit(YahooMcpAudit):
42 def __init__(self) -> None:
43 self.events = []
44
45 def emit(self, event, **values) -> None:
46 self.events.append({"event": event, **values})
47
48
49 class RecordingFallback:
50 def __init__(self) -> None:
51 self.primary_calls = []
52 self.secondary_calls = []
53
54 async def execute_primary(self, instrument_id, targets, **kwargs):
55 self.primary_calls.append((instrument_id, tuple(targets), kwargs))
56 return CapabilityExecutionResult(
57 ("REGIONAL_FALLBACK",),
58 {},
59 tuple(item.requirement_id for item in targets),
60 )
61
62 async def execute_approved_fallbacks(self, instrument_id, targets):
63 self.secondary_calls.append((instrument_id, tuple(targets)))
64 return CapabilityExecutionResult(
65 ("REGIONAL_SECONDARY",),
66 {},
67 tuple(item.requirement_id for item in targets),
68 )
69
70
71 def _free_port() -> int:
72 with socket.socket() as sock:
73 sock.bind(("127.0.0.1", 0))
74 return int(sock.getsockname()[1])
75
76
77 def _capability(requirement, capability, tool, **values):
78 return {
79 "region": "INDIA",
80 "requirementId": requirement,
81 "capability": capability,
82 "tool": tool,
83 "state": "SUPPORTED",
84 **values,
85 }
86
87
88 async def _wait_for_health(url: str, process=None) -> None:
89 for _ in range(100):
90 if process is not None and process.poll() is not None:
91 raise AssertionError(f"gateway exited before health check: {process.returncode}")
92 try:
93 async with httpx.AsyncClient(timeout=0.2) as client:
94 if (await client.get(url)).status_code == 200:
95 return
96 except httpx.HTTPError:
97 pass
98 await asyncio.sleep(0.05)
99 raise AssertionError(f"service did not become healthy: {url}")
100
101
102 @asynccontextmanager
103 async def _actual_stack(scenario: str, capability: dict | None = None):
104 yahoo_port, gateway_port = _free_port(), _free_port()
105 yahoo_settings = YahooMcpSettings(
106 AIP_ENVIRONMENT="TEST",
107 AIP_YAHOO_MCP_TRANSPORT="streamable-http",
108 AIP_YAHOO_MCP_HOST="127.0.0.1",
109 AIP_YAHOO_MCP_PORT=yahoo_port,
110 AIP_YAHOO_MCP_ALLOWED_HOSTS="127.0.0.1,127.0.0.1:*",
111 )
112 audit = RecordingYahooAudit()
113 acquisition = YahooAcquisitionService(yahoo_settings, ticker_factory=factory(scenario))
114 yahoo_app = create_yahoo_mcp_server(
115 yahoo_settings, acquisition=acquisition, audit=audit
116 ).streamable_http_app(stateless_http=True, json_response=True)
117 yahoo_uvicorn = uvicorn.Server(
118 uvicorn.Config(yahoo_app, host="127.0.0.1", port=yahoo_port, log_level="warning")
119 )
120 yahoo_task = asyncio.create_task(yahoo_uvicorn.serve())
121 gateway = None
122 try:
123 await _wait_for_health(f"http://127.0.0.1:{yahoo_port}/health")
124 environment = {
125 **os.environ,
126 "AIP_ENVIRONMENT": "TEST",
127 "AIP_FEATURE_MCP_ENABLED": "true",
128 "AIP_MCP_TRANSPORT": "streamable-http",
129 "AIP_MCP_HOST": "127.0.0.1",
130 "AIP_MCP_PORT": str(gateway_port),
131 "AIP_MCP_ALLOWED_HOSTS": "127.0.0.1,127.0.0.1:*",
132 "AIP_MCP_EXTERNAL_PROVIDERS_ENABLED": "true",
133 "AIP_MCP_EXTERNAL_CALLER_IDENTITIES": "research-engine",
134 "AIP_MCP_YAHOO_ENABLED": "true",
135 "AIP_MCP_YAHOO_TRANSPORT": "streamable-http",
136 "AIP_MCP_YAHOO_ENDPOINT": f"http://127.0.0.1:{yahoo_port}/mcp",
137 "AIP_MCP_YAHOO_AUTH_TYPE": "NONE",
138 "AIP_MCP_YAHOO_CAPABILITIES_JSON": json.dumps(
139 [capability or _capability("LATEST_PRICE", "LATEST_PRICE", "get_quote", maxAgeSeconds=600)]
140 ),
141 "AIP_MCP_YAHOO_MAX_RETRIES": "0",
142 }
143 gateway = subprocess.Popen(
144 [
145 str(ROOT / "ai" / "mcp-gateway" / ".venv" / "Scripts" / "python.exe"),
146 "-m",
147 "app.main",
148 "--transport",
149 "streamable-http",
150 ],
151 cwd=ROOT / "ai" / "mcp-gateway",
152 env=environment,
153 stdout=subprocess.DEVNULL,
154 stderr=subprocess.DEVNULL,
155 )
156 await _wait_for_health(f"http://127.0.0.1:{gateway_port}/health", gateway)
157 yield gateway_port, audit
158 finally:
159 if gateway is not None and gateway.poll() is None:
160 gateway.terminate()
161 try:
162 gateway.wait(timeout=5)
163 except subprocess.TimeoutExpired:
164 gateway.kill()
165 gateway.wait(timeout=5)
166 yahoo_uvicorn.should_exit = True
167 await asyncio.wait_for(yahoo_task, timeout=5)
168
169
170 @pytest.mark.asyncio
171 async def test_targeted_ensure_uses_complete_offline_mcp_chain_and_rereads_readiness():
172 yahoo_port, gateway_port = _free_port(), _free_port()
173 yahoo_settings = YahooMcpSettings(
174 AIP_ENVIRONMENT="TEST",
175 AIP_YAHOO_MCP_TRANSPORT="streamable-http",
176 AIP_YAHOO_MCP_HOST="127.0.0.1",
177 AIP_YAHOO_MCP_PORT=yahoo_port,
178 AIP_YAHOO_MCP_ALLOWED_HOSTS="127.0.0.1,127.0.0.1:*",
179 )
180 audit = RecordingYahooAudit()
181 acquisition = YahooAcquisitionService(yahoo_settings, ticker_factory=factory())
182 yahoo_app = create_yahoo_mcp_server(
183 yahoo_settings, acquisition=acquisition, audit=audit
184 ).streamable_http_app(stateless_http=True, json_response=True)
185 yahoo_uvicorn = uvicorn.Server(
186 uvicorn.Config(
187 yahoo_app,
188 host="127.0.0.1",
189 port=yahoo_port,
190 log_level="warning",
191 lifespan="on",
192 )
193 )
194 yahoo_task = asyncio.create_task(yahoo_uvicorn.serve())
195 gateway = None
196 try:
197 await _wait_for_health(f"http://127.0.0.1:{yahoo_port}/health")
198 capabilities = json.dumps(
199 [_capability("LATEST_PRICE", "LATEST_PRICE", "get_quote", maxAgeSeconds=600)]
200 )
201 environment = {
202 **os.environ,
203 "AIP_ENVIRONMENT": "TEST",
204 "AIP_FEATURE_MCP_ENABLED": "true",
205 "AIP_MCP_TRANSPORT": "streamable-http",
206 "AIP_MCP_HOST": "127.0.0.1",
207 "AIP_MCP_PORT": str(gateway_port),
208 "AIP_MCP_ALLOWED_HOSTS": "127.0.0.1,127.0.0.1:*",
209 "AIP_MCP_EXTERNAL_PROVIDERS_ENABLED": "true",
210 "AIP_MCP_EXTERNAL_CALLER_IDENTITIES": "research-engine",
211 "AIP_MCP_YAHOO_ENABLED": "true",
212 "AIP_MCP_YAHOO_TRANSPORT": "streamable-http",
213 "AIP_MCP_YAHOO_ENDPOINT": f"http://127.0.0.1:{yahoo_port}/mcp",
214 "AIP_MCP_YAHOO_AUTH_TYPE": "NONE",
215 "AIP_MCP_YAHOO_CAPABILITIES_JSON": capabilities,
216 "AIP_MCP_YAHOO_MAX_RETRIES": "0",
217 }
218 gateway_python = ROOT / "ai" / "mcp-gateway" / ".venv" / "Scripts" / "python.exe"
219 gateway = subprocess.Popen(
220 [str(gateway_python), "-m", "app.main", "--transport", "streamable-http"],
221 cwd=ROOT / "ai" / "mcp-gateway",
222 env=environment,
223 stdout=subprocess.DEVNULL,
224 stderr=subprocess.DEVNULL,
225 )
226 await _wait_for_health(f"http://127.0.0.1:{gateway_port}/health", gateway)
227
228 repository = ResearchRepository(
229 settings=Settings(research_live_enabled=False, research_demo_enabled=False),
230 persistence=SqliteResearchPersistence(),
231 )
232 repository.profiles.append(
233 CompanyResearchProfile(
234 instrument_id=INSTRUMENT_ID,
235 company_id=uuid4(),
236 company_name="Hindustan Aeronautics Limited",
237 ticker="HAL",
238 exchange="NSE",
239 mic="XNSE",
240 country="IN",
241 currency="INR",
242 provider_instrument_ids={"YAHOO_FINANCE": "HAL.NS"},
243 )
244 )
245 fallback = RecordingFallback()
246 executor = McpFirstResearchCapabilityExecutor(
247 fallback,
248 repository,
249 HttpExternalResearchToolGateway(
250 f"http://127.0.0.1:{gateway_port}", 3, "research-engine"
251 ),
252 enabled=True,
253 )
254 source = RepositoryResearchReadinessAdapter(repository)
255 runtime = ResearchReadinessRuntime(repository, source, executor)
256
257 before = await runtime.read(INSTRUMENT_ID, jurisdiction="INDIA")
258 assert before.for_requirement("LATEST_PRICE").status is ResearchRequirementStatus.MISSING
259 assert audit.events == []
260 ensured = await runtime.ensure(
261 INSTRUMENT_ID,
262 jurisdiction="INDIA",
263 requirement_ids=("LATEST_PRICE",),
264 correlation_id="offline-chain-5c",
265 )
266 after = await runtime.read(INSTRUMENT_ID, jurisdiction="INDIA")
267
268 assert ensured.executed_capabilities == ("YAHOO_FINANCE_MCP:LATEST_PRICE",)
269 assert after.for_requirement("LATEST_PRICE").status is ResearchRequirementStatus.READY_FRESH
270 assert fallback.primary_calls == [] and fallback.secondary_calls == []
271 assert [item["event"] for item in audit.events] == [
272 "YAHOO_MCP_REQUEST",
273 "YAHOO_MCP_SUCCESS",
274 ]
275 assert audit.events[0]["request_id"] == "offline-chain-5c"
276 records = repository.structured_market_snapshots_for({INSTRUMENT_ID})[INSTRUMENT_ID]
277 assert records[-1].provider == "YAHOO_FINANCE_MCP"
278 assert records[-1].provider_instrument_id == "HAL.NS"
279 finally:
280 if gateway is not None and gateway.poll() is None:
281 gateway.terminate()
282 try:
283 gateway.wait(timeout=5)
284 except subprocess.TimeoutExpired:
285 gateway.kill()
286 gateway.wait(timeout=5)
287 yahoo_uvicorn.should_exit = True
288 await asyncio.wait_for(yahoo_task, timeout=5)
289
290
291 @pytest.mark.asyncio
292 async def test_unsupported_shareholding_skips_yahoo_and_invokes_regional_fallback_once():
293 repository = ResearchRepository(
294 settings=Settings(research_live_enabled=False, research_demo_enabled=False),
295 persistence=SqliteResearchPersistence(),
296 )
297 repository.profiles.append(
298 CompanyResearchProfile(
299 instrument_id=INSTRUMENT_ID,
300 company_id=uuid4(),
301 company_name="RBL Bank Limited",
302 ticker="RBLBANK",
303 exchange="NSE",
304 mic="XNSE",
305 country="IN",
306 currency="INR",
307 provider_instrument_ids={"YAHOO_FINANCE": "RBLBANK.NS"},
308 )
309 )
310
311 class UnsupportedGateway:
312 calls = 0
313
314 async def acquire_requirement(self, *_args, **_kwargs):
315 self.calls += 1
316 from app.yahoo_mcp_acquisition import ExternalMcpAcquisitionError
317
318 raise ExternalMcpAcquisitionError("EXTERNAL_CAPABILITY_UNSUPPORTED")
319
320 fallback, gateway = RecordingFallback(), UnsupportedGateway()
321 executor = McpFirstResearchCapabilityExecutor(
322 fallback, repository, gateway, enabled=True
323 )
324 readiness = await ResearchReadinessRuntime(
325 repository, RepositoryResearchReadinessAdapter(repository), executor
326 ).ensure(
327 INSTRUMENT_ID,
328 jurisdiction="INDIA",
329 requirement_ids=("SHAREHOLDING",),
330 correlation_id="shareholding-fallback-5c",
331 )
332 assert gateway.calls == 1
333 assert len(fallback.primary_calls) == 1
334 assert len(fallback.secondary_calls) == 0
335 assert readiness.executed_capabilities == ("REGIONAL_FALLBACK",)
336
337
338 @pytest.mark.asyncio
339 async def test_first_party_incomplete_result_crosses_mcp_and_invokes_fallback_once():
340 async with _actual_stack("EMPTY") as (gateway_port, audit):
341 repository = ResearchRepository(
342 settings=Settings(research_live_enabled=False, research_demo_enabled=False),
343 persistence=SqliteResearchPersistence(),
344 )
345 repository.profiles.append(
346 CompanyResearchProfile(
347 instrument_id=INSTRUMENT_ID,
348 company_id=uuid4(),
349 company_name="Hindustan Aeronautics Limited",
350 ticker="HAL",
351 exchange="NSE",
352 mic="XNSE",
353 country="IN",
354 currency="INR",
355 provider_instrument_ids={"YAHOO_FINANCE": "HAL.NS"},
356 )
357 )
358 fallback = RecordingFallback()
359 executor = McpFirstResearchCapabilityExecutor(
360 fallback,
361 repository,
362 HttpExternalResearchToolGateway(
363 f"http://127.0.0.1:{gateway_port}", 3, "research-engine"
364 ),
365 enabled=True,
366 )
367 result = await ResearchReadinessRuntime(
368 repository, RepositoryResearchReadinessAdapter(repository), executor
369 ).ensure(
370 INSTRUMENT_ID,
371 jurisdiction="INDIA",
372 requirement_ids=("LATEST_PRICE",),
373 correlation_id="offline-incomplete-5c",
374 )
375 assert len(fallback.primary_calls) == 1
376 assert fallback.secondary_calls == []
377 assert result.executed_capabilities == ("REGIONAL_FALLBACK",)
378 assert [item["event"] for item in audit.events] == [
379 "YAHOO_MCP_REQUEST",
380 "YAHOO_MCP_FAILED",
381 ]
382
383
384 @pytest.mark.asyncio
385 async def test_first_party_financial_success_persists_and_skips_fallback():
386 capability = _capability(
387 "BUSINESS_QUALITY_FACTS", "ANNUAL_FINANCIALS", "get_financials"
388 )
389 async with _actual_stack("SUCCESS", capability) as (gateway_port, audit):
390 repository = ResearchRepository(
391 settings=Settings(research_live_enabled=False, research_demo_enabled=False),
392 persistence=SqliteResearchPersistence(),
393 )
394 repository.profiles.append(
395 CompanyResearchProfile(
396 instrument_id=INSTRUMENT_ID,
397 company_id=uuid4(),
398 company_name="Hindustan Aeronautics Limited",
399 ticker="HAL",
400 exchange="NSE",
401 mic="XNSE",
402 country="IN",
403 currency="INR",
404 provider_instrument_ids={"YAHOO_FINANCE": "HAL.NS"},
405 )
406 )
407 fallback = RecordingFallback()
408 runtime = ResearchReadinessRuntime(
409 repository,
410 RepositoryResearchReadinessAdapter(repository),
411 McpFirstResearchCapabilityExecutor(
412 fallback,
413 repository,
414 HttpExternalResearchToolGateway(
415 f"http://127.0.0.1:{gateway_port}", 3, "research-engine"
416 ),
417 enabled=True,
418 ),
419 )
420 result = await runtime.ensure(
421 INSTRUMENT_ID,
422 jurisdiction="INDIA",
423 requirement_ids=("BUSINESS_QUALITY_FACTS",),
424 correlation_id="offline-financial-5c",
425 )
426 assert result.executed_capabilities == (
427 "YAHOO_FINANCE_MCP:BUSINESS_QUALITY_FACTS",
428 )
429 assert fallback.primary_calls == [] and fallback.secondary_calls == []
430 assert len(repository.financial_facts_for(INSTRUMENT_ID)) >= 6
431 assert [item["event"] for item in audit.events] == [
432 "YAHOO_MCP_REQUEST",
433 "YAHOO_MCP_SUCCESS",
434 ]