main
py 114 lines 6.14 KB
Raw
1 from types import SimpleNamespace
2 from uuid import UUID
3 import pytest
4 from app.news_acquisition import acquire_news
5 from app.source_discovery import CandidateSearchResult, DisabledSearchDiscoveryProvider
6 from app.persistence import SqliteResearchPersistence
7 from app.news_intelligence import SearchRun, EventImpactFeature
8 from app.business_exposure import extract_profile, CompanyBusinessExposureProfile
9 from test_news_intelligence_v2 import NOW, KEY, document, profile
10
11
12 class Provider:
13 def __init__(self,name='web',results=(),fail=False,degraded=False):
14 self.provider_name=name; self.results=results; self.fail=fail; self.calls=[]; self.last_query_degraded=degraded
15 async def discover(self,company,category,window):
16 self.calls.append(window.explicit_queries)
17 if self.fail: raise RuntimeError('SECRET_MUST_NOT_ESCAPE')
18 return list(self.results)
19
20
21 class Repository:
22 def __init__(self):
23 self.settings=SimpleNamespace(market_data_population_request_interval_seconds=1.25)
24 self._persistence=SqliteResearchPersistence(); self.documents={}; self.fetches=[]
25 d=document('Our principal raw material is copper.','OFFICIAL_COMPANY')
26 d.document_id=UUID(int=83); d.canonical_url='https://issuer.test/report'
27 self.documents[d.document_id]=d; self._persistence.upsert_document(d)
28 self._fetcher=SimpleNamespace(fetch=self.fetch)
29 def documents_for(self,*a,**k): return list(self.documents.values())
30 def structured_market_snapshots_for(self,keys): return {k:[] for k in keys}
31 async def append_news_record(self,value): return self._persistence.append_news_record(value)
32 async def _run_blocking_persistence(self,fn,*args): return fn(*args)
33 async def fetch(self,url):
34 self.fetches.append(url)
35 return SimpleNamespace(text='<html><title>Copper reaches record high</title><body>Pressure builds on cable makers.</body></html>',
36 content_type='text/html',final_url=url)
37
38
39 def company():
40 from test_research_readiness_runtime import _profile
41 return _profile(KEY).model_copy(update={'company_id':KEY,'company_name':'Generic Cable Limited','ticker':'GCBL'})
42 def candidate(url='https://publisher.test/news'):
43 return CandidateSearchResult('Copper reaches record high',url,'snippet is not evidence',NOW,'web','id','copper wires','CURRENT_NEWS')
44 async def no_sleep(seconds): pass
45
46
47 @pytest.mark.asyncio
48 @pytest.mark.parametrize('kind,expected',[('empty','SEARCH_COMPLETE_NO_EVENTS'),('fail','SEARCH_FAILED'),('disabled','SEARCH_FAILED'),('degraded','SEARCH_PARTIAL')])
49 async def test_worker_search_outcomes(kind,expected):
50 provider=DisabledSearchDiscoveryProvider() if kind=='disabled' else Provider(fail=kind=='fail',degraded=kind=='degraded')
51 repo=Repository()
52 run,features=await acquire_news(repo,company(),providers=[provider],now=NOW,sleep=no_sleep)
53 assert run.outcome==expected and features==[]
54 assert 'SECRET' not in run.model_dump_json()
55 assert repo._persistence.load_news_records(SearchRun,KEY,as_of=NOW)==[run]
56
57
58 @pytest.mark.asyncio
59 async def test_worker_exposure_queries_persist_features_and_bounded_spacing():
60 repo=Repository(); provider=Provider(results=[candidate()]); spacing=[]
61 async def sleep(seconds): spacing.append(seconds)
62 run,features=await acquire_news(repo,company(),providers=[provider],industry='Wires and cables',now=NOW,sleep=sleep,max_queries=6)
63 assert run.outcome=='SEARCH_COMPLETE_WITH_EVENTS'
64 assert features and features[0].relevance_type=='SECTOR_EXPOSURE'
65 assert any('copper' in q[0].lower() for q in provider.calls)
66 assert len(provider.calls)==6 and spacing==[1.25]*6
67 assert len(repo.fetches)==1
68 assert len(repo._persistence.load_news_records(EventImpactFeature,KEY,as_of=NOW))==1
69 again,second=await acquire_news(repo,company(),providers=[provider],industry='Wires and cables',now=NOW,sleep=no_sleep,max_queries=6)
70 assert len(repo._persistence.load_news_records(EventImpactFeature,KEY,as_of=NOW))==1
71 assert features==second
72
73
74 @pytest.mark.asyncio
75 @pytest.mark.parametrize('failure',['fetch','budget'])
76 async def test_document_incompleteness_never_certifies_no_events(failure):
77 repo=Repository(); provider=Provider(results=[candidate(),candidate('https://publisher.test/second')])
78 if failure=='fetch':
79 async def broken(url): raise RuntimeError('private error')
80 repo._fetcher.fetch=broken
81 run,_=await acquire_news(repo,company(),providers=[provider],now=NOW,sleep=no_sleep,max_documents=1)
82 assert run.outcome=='SEARCH_PARTIAL'
83
84
85 @pytest.mark.asyncio
86 async def test_empty_yahoo_does_not_short_circuit_web():
87 repo=Repository(); yahoo=Provider('yahoo'); web=Provider('web',[candidate()])
88 result,features=await acquire_news(repo,company(),providers=[yahoo,web],now=NOW,sleep=no_sleep)
89 assert result.outcome=='SEARCH_COMPLETE_WITH_EVENTS' and features
90 assert yahoo.calls and web.calls
91
92
93 def test_labelled_profile_fields_and_negation():
94 p=profile('Products: cables, wires. Business segments: industrial. Competitors: Other Ltd. We use copper.')
95 assert p.products_services==['cables','wires']
96 assert p.business_segments==['industrial']
97 assert p.competitor_names==['Other Ltd']
98 from app.news_intelligence import extract_impacts
99 assert not extract_impacts(p,document('Generic Cable Limited denies confirmed fraud','OFFICIAL_COMPANY'),now=NOW)
100
101
102 @pytest.mark.asyncio
103 async def test_persisted_yahoo_empty_plus_web_events_requires_no_yahoo_call():
104 repo=Repository()
105 repo.acquisition_observations_for=lambda key:[dict(requirement_id='CURRENT_NEWS',provider='YAHOO_FINANCE_MCP',
106 observed_at=NOW.isoformat(),outcome='SUCCESS_EMPTY')]
107 result,features=await acquire_news(repo,company(),providers=[Provider(results=[candidate()])],now=NOW,sleep=no_sleep)
108 assert result.outcome=='SEARCH_COMPLETE_WITH_EVENTS' and features
109 assert {p.provider:p.outcome for p in result.providers}['YAHOO_FINANCE_MCP']=='SUCCESS_EMPTY'
110
111
112 def test_competitor_action_in_same_document_not_attributed_to_company():
113 from app.news_intelligence import extract_impacts
114 assert not extract_impacts(profile(),document('Generic Cable Limited reported sales. Other Manufacturer confirmed fraud.'),now=NOW)