Files
AlphaPulse/backend/tests/test_news_research.py
T
eezd 4929a32a0a feat(news): index news packages and research persistent events
Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
2026-11-04 12:09:58 +08:00

436 lines
25 KiB
Python

"""Research and briefing acceptance uses synthetic I/O only."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
from alphapulse.news_risk.research import public_address
async def check():
loop = asyncio.get_running_loop()
monkeypatch.setattr(loop, 'getaddrinfo', AsyncMock(return_value=[(None, None, None, None, ('127.0.0.1', 80))]))
with pytest.raises(ValueError, match='非公开'): await public_address('https://example.invalid/test')
monkeypatch.setattr(loop, 'getaddrinfo', AsyncMock(return_value=[(None, None, None, None, ('8.8.8.8', 80)), (None, None, None, None, ('10.0.0.1', 80))]))
with pytest.raises(ValueError, match='非公开'): await public_address('https://example.invalid/test')
for url in ('file:///etc/passwd', 'http://user:[email protected]', 'http://example.invalid:8000'):
with pytest.raises(ValueError): await public_address(url)
asyncio.run(check())
@pytest.mark.parametrize('discovered', [False, True])
def test_read_web_supports_direct_urls_and_budget(trading, monkeypatch, discovered):
from alphapulse.news_risk import research as module
reader = module.Research()
url = 'https://example.invalid/official'
if discovered:
reader.urls.add(url)
fetch = AsyncMock(return_value={'url': url, 'content': 'Official original'})
monkeypatch.setattr(module, 'fetch_public', fetch)
result = asyncio.run(reader.tool('read_web', {'url': url}))
assert result['discovery_method'] == ('discovered' if discovered else 'direct')
assert result['article_id'] < 0
fetch.assert_awaited_once_with(url)
reader.counts['read_web'] = 4
with pytest.raises(ValueError, match='预算'):
asyncio.run(reader.tool('read_web', {'url': url + '/other'}))
assert fetch.await_count == 1
def test_briefing_covers_all_packages_across_batches(trading, monkeypatch):
from alphapulse.news_risk import briefing
provider = SimpleNamespace(max_tokens=100, context_window=7000)
inputs = [{'id': i, 'revision': 2, 'importance_stars': 3, 'result': {'summary': 'x'*1600}} for i in range(15)]
batches = []
async def complete(provider, system, payload, schema):
assert briefing.fits_context(provider, system, payload, schema)
if schema is briefing.Ranked:
batches.append(payload['packages'])
return briefing.Ranked(reading=[briefing.Reading(id=p['id'], revision=2, priority=p['id'], reason='Read') for p in payload['packages']], notes='Notes'), {}
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Unknown')
return briefing.Synthesis(summary='Unknown', market='Unknown', short_term=trend, medium_term=trend, references=[]), {}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(provider, 'rules', inputs))
assert len(batches) > 1
assert [p['id'] for p in result['reading']] == list(reversed(range(15)))
@pytest.mark.parametrize('ids', [[], [1, 1], [2]])
def test_briefing_rejects_missing_duplicate_and_unknown_ids(trading, monkeypatch, ids):
from alphapulse.news_risk import briefing
async def complete(*args):
return briefing.Ranked(reading=[briefing.Reading(id=i, revision=1, priority=50, reason='Read') for i in ids], notes='Notes'), {}
monkeypatch.setattr(briefing, 'complete', complete)
with pytest.raises(ValueError, match='遗漏、重复'):
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), 'rules', [{'id': 1, 'revision': 1, 'importance_stars': 5}]))
def test_high_importance_positive_package_is_valid(trading):
from alphapulse.news_risk import pipeline
from test_news_pipeline import event_result
result = event_result(pipeline, [pipeline.Evidence(article_id=1, quote='突破')])
result.risk_stars = 0; result.importance_stars = 5
pipeline.validate_event(result, {1: {'title': '科技突破'}})
assert result.importance_stars == 5 and result.risk_stars == 0
def test_public_fetch_pins_address_and_rechecks_redirect(trading, monkeypatch):
import httpx
from alphapulse.news_risk import research
calls = []
original = httpx.AsyncClient
async def resolve(url):
from urllib.parse import urlsplit
if 'private.invalid' in url: raise ValueError('非公开地址')
return urlsplit(url), '8.8.8.8'
async def handle(request):
calls.append(request)
assert request.url.host == '8.8.8.8' and request.headers['host'] == 'public.invalid'
assert request.extensions['sni_hostname'] == 'public.invalid'
return httpx.Response(302, headers={'location': 'http://private.invalid'})
monkeypatch.setattr(research, 'public_address', resolve)
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: original(transport=httpx.MockTransport(handle)))
with pytest.raises(ValueError, match='非公开'):
asyncio.run(research.fetch_public('https://public.invalid'))
assert len(calls) == 1
def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
from alphapulse.news_risk.research import Research
research = Research()
record = {'tool': 'read_web', 'arguments': {'url': 'https://example.invalid'}, 'result': {'article_id': -1, 'content': 'Original'}}
research.restore([record])
assert asyncio.run(research.tool('read_web', record['arguments']))['content'] == 'Original'
assert not research.records and research.counts['read_web'] == 0
def test_restored_news_is_sent_before_reusing_its_reference(trading, monkeypatch):
import json
from pydantic import BaseModel
from alphapulse.news_risk import research
class Result(BaseModel):
summary: str
review = research.Research({'id': 42})
review.restore([{'tool': 'read_news', 'arguments': {'id': 123},
'result': {'article_id': -1, 'news_id': 123, 'content': 'Restored original'}}])
rounds = []
async def stream(provider, system, messages, tools, effort):
rounds.append(len(messages))
if len(rounds) == 2:
assert json.loads(messages[-1]['result'])['content'] == 'Restored original'
if len(rounds) == 3:
reply = json.loads(messages[-1]['result'])
assert reply['article_id'] == -1 and reply['status'] == 'already_provided'
assert 'content' not in reply
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit',
'arguments': {'summary': 'Restored original'}}])
else:
yield research.llm.Completion(tool_calls=[{'id': str(len(rounds)), 'name': 'read_news', 'arguments': {'id': 123}}])
monkeypatch.setattr(research.llm, 'stream', stream)
result, _ = asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None),
'rules', {'articles': []}, Result))
assert result.summary == 'Restored original' and len(rounds) == 3
assert review.counts['read'] == 0 and not review.records
def test_current_input_takes_precedence_over_restored_news(trading, monkeypatch):
from pydantic import BaseModel
from alphapulse.news_risk import research
class Result(BaseModel):
summary: str
review = research.Research()
review.restore([{'tool': 'read_news', 'arguments': {'id': 123},
'result': {'article_id': -1, 'news_id': 123, 'content': 'Old original'}}])
async def stream(provider, system, messages, tools, effort):
if len(messages) == 1:
yield research.llm.Completion(tool_calls=[{'id': 'read', 'name': 'read_news', 'arguments': {'id': 123}}])
else:
import json
reply = json.loads(messages[-1]['result'])
assert reply['article_id'] == 9 and reply['status'] == 'already_provided'
assert 'Old original' not in json.dumps(messages)
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit', 'arguments': {'summary': 'Current original'}}])
monkeypatch.setattr(research.llm, 'stream', stream)
asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None), 'rules',
{'articles': [{'id': 9, 'news_id': 123, 'content': 'Current original'}]}, Result))
assert review.counts['read'] == 0
def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, monkeypatch):
from alphapulse.news_risk import research
from pydantic import BaseModel
class Result(BaseModel):
summary: str
rounds = []
async def stream(provider, system, messages, tools, effort):
rounds.append(tools)
if len(rounds) == 9:
assert [tool.name for tool in tools] == ['submit']
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit', 'arguments': {'summary': '证据不足'}}])
else:
yield research.llm.Completion(tool_calls=[{'id': str(len(rounds)), 'name': 'unknown_tool', 'arguments': {}}])
monkeypatch.setattr(research.llm, 'stream', stream)
review = research.Research()
result, _ = asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None), 'rules', {}, Result))
assert result.summary == '证据不足'
assert len(rounds) == 9 and len(review.records) == 8
assert all('error' in record['result'] for record in review.records)
@pytest.mark.parametrize('mode,created', [('unchanged', False), ('ordinary_wait', False), ('ordinary_due', True), ('urgent_wait', False), ('urgent_due', True), ('expired', True)])
def test_reading_updates_only_for_changes_and_respects_aggregation(trading, monkeypatch, mode, created):
from datetime import UTC, datetime, timedelta
from alphapulse.news_risk import worker, events
now = datetime.now(UTC)
event = SimpleNamespace(id=1, published_revision=2, importance_stars=5 if mode.startswith('urgent') else 3,
category='politics', tags=['政策'], result={'summary': '当前说明'}, analyzed_at=now)
semantic = [{'kind': 'package', 'id': event.id, 'importance_stars': event.importance_stars, 'result': event.result}]
fingerprint = worker.briefing.fingerprint(semantic, {}) if mode == 'unchanged' else 'previous'
latest = SimpleNamespace(created_at=now-timedelta(seconds=30 if mode == 'urgent_wait' else 120), quotes={'fingerprint': fingerprint})
if mode in ('ordinary_due', 'expired'): latest.quotes['dirty_since'] = (now-timedelta(minutes=16)).isoformat()
session = AsyncMock()
session.__aenter__.return_value = session
session.get.return_value = SimpleNamespace(enabled=True)
session.scalar.side_effect = [latest, None, None] + ([None] if mode == 'expired' else [])
session.scalars.return_value = SimpleNamespace(all=lambda: [] if mode == 'expired' else [event])
monkeypatch.setattr(events, 'reading_inputs', AsyncMock(return_value=[] if mode == 'expired' else [{'kind': 'package', 'id': event.id, 'revision': event.published_revision, 'importance_stars': event.importance_stars, 'category': event.category, 'tags': event.tags, 'result': event.result}]))
added = []
session.add = added.append
monkeypatch.setattr(worker, 'SessionLocal', lambda: session)
monkeypatch.setattr(worker, 'configuration', AsyncMock(return_value=(SimpleNamespace(model='advanced'), 'rules', {}, 'rules-key')))
monkeypatch.setattr(worker.briefing, 'generate', AsyncMock(return_value={'reading': [], 'synthesis': None, 'usage': []}))
asyncio.run(worker.outlook())
assert bool(added) is created
if created: assert added[0].state == 'done'
if mode == 'ordinary_wait': assert latest.quotes['dirty_since']
def test_truncated_reading_batches_split_until_all_versions_are_ranked(trading, monkeypatch):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import OutputTruncated
inputs = [{'id': i, 'revision': 3, 'importance_stars': 3, 'result': {'summary': 'Short'}} for i in range(1, 41)]
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
if schema is briefing.Ranked:
if len(payload['packages']) > 5: raise OutputTruncated('Synthetic output limit', {'output_tokens': 256})
return briefing.Ranked(reading=[briefing.Reading(id=p['id'], revision=3, priority=100-p['id'], reason='Read') for p in payload['packages']], notes='Notes'), {'output_tokens': 100}
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Unknown')
return briefing.Synthesis(summary='Unknown', market='Unknown', short_term=trend, medium_term=trend, references=[]), {}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', inputs))
assert [r['id'] for r in result['reading']] == list(range(1, 41))
assert all(r['revision'] == 3 for r in result['reading'])
assert len(result['usage']) == len(calls)
assert any(u.get('output_tokens') == 256 for u in result['usage'])
def test_single_package_truncation_is_bounded_and_does_not_retry_other_errors(trading, monkeypatch):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import OutputTruncated
complete = AsyncMock(side_effect=OutputTruncated('Synthetic output limit'))
monkeypatch.setattr(briefing, 'complete', complete)
with pytest.raises(OutputTruncated, match='单包排序'):
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', [{'id': 1, 'revision': 1, 'importance_stars': 3}]))
assert complete.await_count == 1
complete = AsyncMock(side_effect=ValueError('Malformed ranking'))
monkeypatch.setattr(briefing, 'complete', complete)
with pytest.raises(ValueError, match='Malformed'):
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', [{'id': 1, 'revision': 1}, {'id': 2, 'revision': 1}]))
assert complete.await_count == 1
@pytest.mark.parametrize('split', [False, True])
def test_terminal_truncation_keeps_completed_and_split_attempt_usage(trading, monkeypatch, split):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import OutputTruncated
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
if split and len(calls) == 1:
raise OutputTruncated('Split batch', {'output_tokens': 30})
if len(calls) == (2 if split else 1):
return briefing.Ranked(reading=[briefing.Reading(**p, priority=50, reason='Read') for p in payload['expected']],
notes='Notes'), {'output_tokens': 120}
raise OutputTruncated('Single item', {'output_tokens': 20})
monkeypatch.setattr(briefing, 'complete', complete)
inputs = [{'id': i, 'revision': 1, 'importance_stars': 3} for i in range(2 if split else 13)]
with pytest.raises(OutputTruncated, match='单包排序') as error:
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=1000, context_window=200000), 'rules', inputs, ranking_only=True))
assert [u['output_tokens'] for u in error.value.usage['rounds']] == ([30, 120, 20] if split else [120, 20])
assert len(calls) == (3 if split else 2)
@pytest.mark.parametrize('change,expected', [('links', False), ('verdict', True), ('risk', True), ('fact', True)])
def test_reposts_do_not_trigger_advanced_calls_but_review_changes_do(trading, monkeypatch, change, expected):
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from alphapulse.news_risk import worker, briefing
now = datetime.now(UTC)
inputs = [{'id': 1, 'revision': 1, 'importance_stars': 5, 'result': {'facts': '公开说明', 'risk_stars': 1,
'evidence_refs': [{'article_id': 1, 'quote': '公开说明'}],
'claims': [{'statement': '公开说明', 'verdict': 'partial', 'evidence_refs': [{'article_id': 1, 'quote': '公开说明'}]}]}}]
semantic = briefing.semantic_packages(inputs)
latest = SimpleNamespace(created_at=now-timedelta(minutes=2), quotes={'fingerprint': briefing.fingerprint(semantic, {}),
'important': {'1': briefing.fingerprint(semantic[0], {})}})
updated = deepcopy(inputs)
updated[0]['revision'] = 2
updated[0]['result']['evidence_refs'].append({'article_id': 2, 'quote': '公开说明'})
updated[0]['result']['claims'][0]['evidence_refs'].append({'article_id': 2, 'quote': '公开说明'})
if change == 'verdict': updated[0]['result']['claims'][0]['verdict'] = 'supported'
if change == 'risk': updated[0]['result']['risk_stars'] = 5
if change == 'fact': updated[0]['result']['facts'] = '官方修订说明'
assert (briefing.semantic_packages(updated) != semantic) is expected
session = AsyncMock(); session.__aenter__.return_value = session
session.get.return_value = SimpleNamespace(enabled=True); session.scalar.side_effect = [latest, None, None]
session.scalars.return_value = SimpleNamespace(all=lambda: [SimpleNamespace(id=1, published_revision=2,
importance_stars=5, category='politics', tags=[], result=updated[0]['result'], analyzed_at=now)])
from alphapulse.news_risk import events
monkeypatch.setattr(events, 'reading_inputs', AsyncMock(return_value=[{**p, 'kind': 'package', 'category': 'politics', 'tags': []} for p in updated]))
session.add = lambda value: None
generated = AsyncMock(return_value={'reading': [], 'synthesis': None, 'usage': []})
monkeypatch.setattr(worker, 'SessionLocal', lambda: session)
monkeypatch.setattr(worker, 'configuration', AsyncMock(return_value=(SimpleNamespace(model='advanced'), 'rules', {}, 'key')))
monkeypatch.setattr(briefing, 'generate', generated)
asyncio.run(worker.outlook())
assert bool(generated.await_count) is expected
def test_current_package_read_needs_no_io_or_budget(trading):
from alphapulse.news_risk.research import Research
research = Research({'id': 42, 'revision': 3})
result = asyncio.run(research.tool('read_package', {'id': 42}))
assert result['status'] == 'already_provided' and result['revision'] == 3
assert research.counts['read'] == 0 and not research.records
def test_provided_original_is_reused_without_io(trading, monkeypatch):
from alphapulse.news_risk import research
from pydantic import BaseModel
class Result(BaseModel):
summary: str
async def stream(provider, system, messages, tools, effort):
if len(messages) == 1:
yield research.llm.Completion(tool_calls=[{'id': 'read', 'name': 'read_news', 'arguments': {'id': 123}}])
else:
import json
response = json.loads(messages[-1]['result'])
assert response['article_id'] == 9 and 'content' not in response
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit', 'arguments': {'summary': 'Done'}}])
monkeypatch.setattr(research.llm, 'stream', stream)
review = research.Research()
asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None), '',
{'articles': [{'id': 9, 'news_id': 123, 'content': 'Original'}]}, Result))
assert review.sources[9]['content'] == 'Original'
assert review.counts['read'] == 0 and not review.records
def test_web_unavailable_short_circuits_only_this_review(trading, monkeypatch):
from alphapulse.news_risk import research
session = AsyncMock()
session.__aenter__.return_value = session
monkeypatch.setattr(research, 'SessionLocal', lambda: session)
client = AsyncMock()
client.__aenter__.return_value = client
response = SimpleNamespace(raise_for_status=lambda: None,
json=lambda: {'results': [], 'unresponsive_engines': [['engine', 'CAPTCHA']]})
client.get.return_value = response
monkeypatch.setattr(research.httpx, 'AsyncClient', lambda **kwargs: client)
review = research.Research()
assert asyncio.run(review.tool('search_web', {'query': 'first query'}))['status'] == 'unavailable'
assert asyncio.run(review.tool('search_web', {'query': 'different query'}))['status'] == 'unavailable'
assert client.get.await_count == 1 and review.counts['search_web'] == 1
assert research.Research().web_unavailable is None
def test_ranking_repairs_identity_once_and_accounts_usage(trading, monkeypatch):
from alphapulse.news_risk import briefing
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
ids = [] if len(calls) == 1 else [p['id'] for p in payload['packages']]
return briefing.Ranked(reading=[briefing.Reading(id=i, revision=2, priority=50, reason='Read') for i in ids], notes='Notes'), {'output_tokens': 10}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
assert len(calls) == 2 and calls[1]['correction']['errors']['missing'] == [('package', 1, 2)]
assert len(result['usage']) == 2 and result['reading'][0]['id'] == 1
def test_ranking_missing_revision_repaired_with_explicit_identity(trading, monkeypatch):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import SubmissionFailure
from pydantic import ValidationError
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
if len(calls) == 1:
try: briefing.Ranked.model_validate({'reading': [{'id': 1, 'priority': 50, 'reason': 'Read'}], 'notes': 'Notes'})
except ValidationError as error: raise SubmissionFailure('Missing revision', {'output_tokens': 10}) from error
return briefing.Ranked(reading=[briefing.Reading(**payload['expected'][0], priority=50, reason='Read')], notes='Notes'), {}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
assert len(calls) == 2 and len(result['usage']) == 2
def test_ranking_batches_are_bounded_and_cover_every_identity(trading, monkeypatch):
from alphapulse.news_risk import briefing
counts = []
async def complete(provider, system, payload, schema):
counts.append(len(payload['packages']))
return briefing.Ranked(reading=[briefing.Reading(**p, priority=50, reason='Read') for p in payload['expected']], notes='Notes'), {}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=1000, context_window=200000), '',
[{'id': i, 'revision': 1, 'importance_stars': 3} for i in range(25)], ranking_only=True))
assert counts == [12, 12, 1] and len(result['reading']) == 25
def test_package_search_excludes_current_package(trading, monkeypatch):
from alphapulse.news_risk import research
from sqlalchemy.dialects.postgresql import dialect
statements = []
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def scalars(self, statement):
statements.append(statement.compile(dialect=dialect()))
return SimpleNamespace(all=lambda: [])
monkeypatch.setattr(research, 'SessionLocal', Session)
review = research.Research({'id': 42})
asyncio.run(review.tool('search_packages', {'query': 'policy', 'keywords': ['policy']}))
assert 'news_ai_summaries.id !=' in str(statements[0]) and 42 in statements[0].params.values()
@pytest.mark.parametrize('result,expected', [({'title': '媒体称11月2日拟开会'}, '媒体称11月2日拟开会'), (None, '明天开会')])
def test_package_api_returns_fact_title_without_legacy_rating(trading, result, expected):
from alphapulse.api.risk import package_out
from alphapulse.models.risk import NewsSummary
session = AsyncMock()
session.scalar.return_value = 1
session.get.return_value = None
package = NewsSummary(id=1, title='明天开会', result=result, replacement_ids=[])
assert asyncio.run(package_out(package, session))['title'] == '明天开会'
def test_ranking_invalid_identity_stops_after_one_repair(trading, monkeypatch):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import SubmissionFailure
complete = AsyncMock(return_value=(briefing.Ranked(reading=[], notes='Notes'), {'output_tokens': 10}))
monkeypatch.setattr(briefing, 'complete', complete)
with pytest.raises(SubmissionFailure, match='missing') as error:
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
assert complete.await_count == 2
assert len(error.value.usage['failed_batch']['rounds']) == 2
def test_direct_web_read_preserves_public_reader_rejection(trading, monkeypatch):
from alphapulse.news_risk import research as module
reader = module.Research()
fetch = AsyncMock(side_effect=ValueError('不允许访问非公开地址'))
monkeypatch.setattr(module, 'fetch_public', fetch)
with pytest.raises(ValueError, match='非公开'):
asyncio.run(reader.tool('read_web', {'url': 'http://127.0.0.1'}))
assert not reader.sources