199 lines
10 KiB
Python
199 lines
10 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.core.public_http 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_research import tools 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_high_importance_positive_package_is_valid(trading):
|
|
from alphapulse.news_research 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.core import public_http as 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_research.tools 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_research import tools as 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_research import tools as 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_research import tools as 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)
|
|
|
|
|
|
def test_provided_original_is_reused_without_io(trading, monkeypatch):
|
|
from alphapulse.news_research import tools as 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_research import tools as 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_direct_web_read_preserves_public_reader_rejection(trading, monkeypatch):
|
|
from alphapulse.news_research import tools 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
|