refactor(news): remove retired research workflow
This commit is contained in:
1 parent
f3894c3728
commit
d79a10c2e6
69 files changed
+460
-6713
No files matched your search
@@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
|
||||
def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
|
||||
from alphapulse.news_risk.research import public_address
|
||||
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))]))
|
||||
@@ -20,7 +20,7 @@ def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
|
||||
|
||||
@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
|
||||
from alphapulse.news_research import tools as module
|
||||
reader = module.Research()
|
||||
url = 'https://example.invalid/official'
|
||||
if discovered:
|
||||
@@ -37,36 +37,8 @@ def test_read_web_supports_direct_urls_and_budget(trading, monkeypatch, discover
|
||||
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 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
|
||||
@@ -76,7 +48,7 @@ def test_high_importance_positive_package_is_valid(trading):
|
||||
|
||||
def test_public_fetch_pins_address_and_rechecks_redirect(trading, monkeypatch):
|
||||
import httpx
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.core import public_http as research
|
||||
calls = []
|
||||
original = httpx.AsyncClient
|
||||
async def resolve(url):
|
||||
@@ -96,7 +68,7 @@ def test_public_fetch_pins_address_and_rechecks_redirect(trading, monkeypatch):
|
||||
|
||||
|
||||
def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
|
||||
from alphapulse.news_risk.research import Research
|
||||
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])
|
||||
@@ -107,7 +79,7 @@ def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
|
||||
def test_restored_news_is_sent_before_reusing_its_reference(trading, monkeypatch):
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
review = research.Research({'id': 42})
|
||||
@@ -135,7 +107,7 @@ def test_restored_news_is_sent_before_reusing_its_reference(trading, monkeypatch
|
||||
|
||||
def test_current_input_takes_precedence_over_restored_news(trading, monkeypatch):
|
||||
from pydantic import BaseModel
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
review = research.Research()
|
||||
@@ -157,7 +129,7 @@ def test_current_input_takes_precedence_over_restored_news(trading, monkeypatch)
|
||||
|
||||
|
||||
def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
from pydantic import BaseModel
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
@@ -177,135 +149,8 @@ def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, mo
|
||||
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 alphapulse.news_research import tools as research
|
||||
from pydantic import BaseModel
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
@@ -326,7 +171,7 @@ def test_provided_original_is_reused_without_io(trading, monkeypatch):
|
||||
|
||||
|
||||
def test_web_unavailable_short_circuits_only_this_review(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
session = AsyncMock()
|
||||
session.__aenter__.return_value = session
|
||||
monkeypatch.setattr(research, 'SessionLocal', lambda: session)
|
||||
@@ -343,90 +188,8 @@ def test_web_unavailable_short_circuits_only_this_review(trading, monkeypatch):
|
||||
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
|
||||
from alphapulse.news_research import tools as module
|
||||
reader = module.Research()
|
||||
fetch = AsyncMock(side_effect=ValueError('不允许访问非公开地址'))
|
||||
monkeypatch.setattr(module, 'fetch_public', fetch)
|
||||
|
||||
Reference in new issue
Block a user